From 6e4f3e045458e50cca7d0bda4f2df62d246e1207 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 15:51:09 +0000 Subject: [PATCH 0001/1100] docs: add Shared LLM Access feature to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1efb234e26..b12f6074ac 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Slips key features are: * **Traffic Analysis Flexibility**: Slips can analyze network traffic in real-time, PCAP files, and network flows from popular tools like Suricata, Zeek/Bro, and Argus. * **Threat Intelligence Updates**: Slips continuously updates threat intelligence files and databases, providing relevant detections as updates occur. * **Integration with External Platforms**: Modules in Slips can look up IP addresses on external platforms such as VirusTotal and RiskIQ. +* **Shared LLM Access**: Slips can expose configured LLM backends such as Ollama, OpenAI, and Anthropic to other modules through Redis channels. * **Graphical User Interface**: Slips provides a console graphical user interface (Kalipso) and a web interface for displaying detection with graphs and tables. * **Peer-to-Peer (P2P) Module**: Slips includes a complex automatic system to find other peers in the network and share IoC data automatically in a balanced, trusted manner. The P2P module can be enabled as needed. * **Docker Implementation**: Running Slips through Docker on Linux systems is simplified, allowing real-time traffic analysis. From 489edbdb53772d88247086da06c61c399df30d4e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 15:51:29 +0000 Subject: [PATCH 0002/1100] config: add shared LLM service module configuration --- config/slips.yaml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/config/slips.yaml b/config/slips.yaml index 5e307f143e..ab0525ec1c 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -201,6 +201,42 @@ modules: # by setting this variable to "true" value the time will be human readable. timeline_human_timestamp: true +############################# +llm: + # Enable the shared LLM service module. + enabled: false + + # If a request does not specify a backend, this one is used. + default_backend: local_qwen + + # Number of worker threads processing LLM requests in parallel. + worker_threads: 2 + + # Maximum number of pending requests kept in memory. + queue_size: 100 + + # Each backend is a named connection that other modules can select by name. + backends: + local_qwen: + provider: ollama + model: qwen2.5:3b + base_url: http://127.0.0.1:11434 + timeout: 120 + + openai_default: + provider: openai + model: gpt-4o-mini + base_url: https://api.openai.com/v1 + api_key_env: OPENAI_API_KEY + timeout: 60 + + claude_default: + provider: anthropic + model: claude-sonnet-4-5 + base_url: https://api.anthropic.com + api_key_env: ANTHROPIC_API_KEY + timeout: 60 + ############################# flowmldetection: # This is a module that uses machine learning for detection. From 31cb74f6efd51005162d374ced30cb44c4ce214e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 15:51:55 +0000 Subject: [PATCH 0003/1100] docs: update detection modules documentation to include LLM module details --- docs/detection_modules.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/detection_modules.md b/docs/detection_modules.md index 4c767d72db..94915270d3 100644 --- a/docs/detection_modules.md +++ b/docs/detection_modules.md @@ -123,9 +123,24 @@ tr:nth-child(even) { module to detect malicious flows using machine learning ✅ + + LLM + shared service module that sends prompts to configured OpenAI, Anthropic, or Ollama backends and publishes the replies for other modules + ✅ + +## LLM Module + +The LLM module is a shared service for other Slips modules. + +It listens on the Redis channel `llm_request`, sends the request to the selected +configured backend, and publishes the result on `llm_response`. + +For the full request and response format, backend configuration, and examples, +see [LLM Module](llm_module.md). + ## HTTPS Anomaly Detection Module For the full technical description of the HTTPS anomaly detector (features, training, adaptation, z-score logic, evidence format, and configuration), see: From c332a886a9929d60483ea1ef3c97b3e05a0ecd50 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 15:52:05 +0000 Subject: [PATCH 0004/1100] docs: add LLM module section to documentation index --- docs/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 14669fe5ac..5c9b913531 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,6 +17,8 @@ This documentation gives an overview how Slips works, how to use it and how to h - **Detection modules**. Explanation of detection modules in Slips, types of input and output. See :doc:`Detection modules `. +- **LLM module**. Shared access to configured LLM backends from other Slips modules. See :doc:`LLM module `. + - **HTTPS anomaly detection**. Detailed design and behavior of the HTTPS anomaly detector. See :doc:`HTTPS anomaly detection `. - **Architecture**. Internal architecture of Slips (profiles, timewindows), the use of Zeek and connection to Redis. See :doc:`Architecture `. @@ -51,6 +53,7 @@ This documentation gives an overview how Slips works, how to use it and how to h usage architecture detection_modules + llm_module https_anomaly_detection flowalerts features From dd413b61d9ef9f2a5867723e93b4a86dd7173867 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 15:52:18 +0000 Subject: [PATCH 0005/1100] config: add LLM configuration methods to ConfigParser --- slips_files/common/parsers/config_parser.py | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index 2bd7fb5e5d..b79e0456c0 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -696,6 +696,36 @@ def timeline_human_timestamp(self): "modules", "timeline_human_timestamp", False ) + def llm_enabled(self) -> bool: + value = self.read_configuration("llm", "enabled", False) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") + + def llm_default_backend(self) -> str: + value = self.read_configuration("llm", "default_backend", "") + return str(value or "").strip() + + def llm_worker_threads(self) -> int: + value = self.read_configuration("llm", "worker_threads", 2) + try: + value = int(value) + except (TypeError, ValueError): + value = 2 + return max(1, value) + + def llm_queue_size(self) -> int: + value = self.read_configuration("llm", "queue_size", 100) + try: + value = int(value) + except (TypeError, ValueError): + value = 100 + return max(1, value) + + def llm_backends(self) -> dict: + backends = self.read_configuration("llm", "backends", {}) + return backends if isinstance(backends, dict) else {} + def analysis_direction(self): """ Controls which traffic flows are processed and analyzed by SLIPS. @@ -903,6 +933,9 @@ def get_disabled_modules(self, input_type: str) -> list: if not self.reading_flows_from_cyst(): to_ignore.append("cyst") + if not self.llm_enabled(): + to_ignore.append("llm") + return to_ignore def get_cpu_profiler_enable(self): From 440555e3c4e824da269b83e133dbcd749241689b Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 15:52:37 +0000 Subject: [PATCH 0006/1100] constants: add LLM request and response keys --- slips_files/core/database/redis_db/constants.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/slips_files/core/database/redis_db/constants.py b/slips_files/core/database/redis_db/constants.py index e14b49ef90..111a89df17 100644 --- a/slips_files/core/database/redis_db/constants.py +++ b/slips_files/core/database/redis_db/constants.py @@ -115,3 +115,5 @@ class Channels: GIVE_TI = "get_modified_profiles_since" NEW_ZEEK_FIELDS_LINE = "new_zeek_fields_line" CONTROL_CHANNEL = "control_channel" + LLM_REQUEST = "llm_request" + LLM_RESPONSE = "llm_response" From 7482b752ae0f75e8960905d7248190a815b27158 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 15:52:48 +0000 Subject: [PATCH 0007/1100] constants: add LLM request and response to RedisDB fields --- slips_files/core/database/redis_db/database.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/slips_files/core/database/redis_db/database.py b/slips_files/core/database/redis_db/database.py index 9872f5490d..90a4ac4314 100644 --- a/slips_files/core/database/redis_db/database.py +++ b/slips_files/core/database/redis_db/database.py @@ -106,6 +106,8 @@ class RedisDB( "slips2fides", "iris_internal", "new_zeek_fields_line", + "llm_request", + "llm_response", } separator = "_" From 73dac94efcbc1e8835e96f6b5243992379e60b17 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 15:53:16 +0000 Subject: [PATCH 0008/1100] tests: add LLM object creation method to ModuleFactory --- tests/module_factory.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/module_factory.py b/tests/module_factory.py index 27d6880d93..1a0815ff5c 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -133,6 +133,42 @@ def create_http_analyzer_obj(self, mock_db): http_analyzer.print = Mock() return http_analyzer + @patch(MODULE_DB_MANAGER, name="mock_db") + def create_llm_obj(self, mock_db): + from modules.llm.llm import LLM + + conf = Mock() + conf.llm_enabled = Mock(return_value=True) + conf.llm_default_backend = Mock(return_value="local_qwen") + conf.llm_worker_threads = Mock(return_value=1) + conf.llm_queue_size = Mock(return_value=10) + conf.llm_backends = Mock( + return_value={ + "local_qwen": { + "provider": "ollama", + "model": "qwen2.5:3b", + "base_url": "http://127.0.0.1:11434", + "timeout": 60, + } + } + ) + + llm = LLM( + logger=self.logger, + output_dir="dummy_output_dir", + redis_port=6379, + termination_event=Mock(), + slips_args=Mock(), + conf=conf, + ppid=Mock(), + bloom_filters_manager=Mock(), + ) + llm.db.channels.LLM_REQUEST = "llm_request" + llm.db.channels.LLM_RESPONSE = "llm_response" + llm.channels = {"llm_request": llm.c1} + llm.print = Mock() + return llm + @patch(MODULE_DB_MANAGER, name="mock_db") def create_fides_module_obj(self, mock_db): from modules.fidesModule.fidesModule import FidesModule From 8ab6bdd6f25881fa29bea752e643948b8f37cfb6 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:09:18 +0000 Subject: [PATCH 0009/1100] database: add methods to set and get available LLM backends --- slips_files/core/database/database_manager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/slips_files/core/database/database_manager.py b/slips_files/core/database/database_manager.py index 4373ce7eae..22c555a0d4 100644 --- a/slips_files/core/database/database_manager.py +++ b/slips_files/core/database/database_manager.py @@ -801,6 +801,12 @@ def incr_msgs_received_in_channel(self, *args, **kwargs): def get_enabled_modules(self, *args, **kwargs): return self.rdb.get_enabled_modules(*args, **kwargs) + def set_available_llm_backends(self, *args, **kwargs): + return self.rdb.set_available_llm_backends(*args, **kwargs) + + def get_available_llm_backends(self, *args, **kwargs): + return self.rdb.get_available_llm_backends(*args, **kwargs) + def get_msgs_received_at_runtime(self, *args, **kwargs): return self.rdb.get_msgs_received_at_runtime(*args, **kwargs) From ee991fe4685cd227c2badd8d98353326866608d1 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:09:32 +0000 Subject: [PATCH 0010/1100] docs: add LLM module documentation with configuration and usage details --- docs/llm_module.md | 226 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/llm_module.md diff --git a/docs/llm_module.md b/docs/llm_module.md new file mode 100644 index 0000000000..399c2f3368 --- /dev/null +++ b/docs/llm_module.md @@ -0,0 +1,226 @@ +# LLM Module + +The `LLM` module provides shared access to configured language model backends +for the rest of Slips. + +Instead of each module managing its own API keys, URLs, and HTTP logic, they +can publish a request to Redis and read the answer from a shared response +channel. + +## What It Does + +The module: + +1. Reads LLM backend configuration from `config/slips.yaml` +2. Connects to one or more configured providers +3. Subscribes to the Redis channel `llm_request` +4. Sends each request to the selected backend +5. Publishes the result to `llm_response` + +Supported providers: + +- `ollama` +- `openai` +- `anthropic` + +## Configuration + +Example section in `config/slips.yaml`: + +```yaml +llm: + enabled: true + default_backend: local_qwen + worker_threads: 2 + queue_size: 100 + backends: + local_qwen: + provider: ollama + model: qwen2.5:3b + base_url: http://127.0.0.1:11434 + timeout: 120 + openai_default: + provider: openai + model: gpt-4o-mini + base_url: https://api.openai.com/v1 + api_key_env: OPENAI_API_KEY + timeout: 60 + claude_default: + provider: anthropic + model: claude-sonnet-4-5 + base_url: https://api.anthropic.com + api_key_env: ANTHROPIC_API_KEY + timeout: 60 +``` + +Each backend is a named connection. Other modules select it by name using the +`backend` field in the request. + +## Discovery Helper + +Other modules should discover available backends with: + +```python +available = self.db.get_available_llm_backends() +``` + +This returns only runtime-ready backends: + +```json +{ + "default_backend": "local_qwen", + "backends": { + "local_qwen": { + "provider": "ollama", + "model": "qwen2.5:3b" + }, + "openai_default": { + "provider": "openai", + "model": "gpt-4o-mini" + } + } +} +``` + +During startup the helper may temporarily return: + +```json +{ + "default_backend": "", + "backends": {} +} +``` + +Caller modules should retry later if they need LLM access and the registry is +still empty. + +## Redis Contract + +### Request channel + +Channel: `llm_request` + +Minimal request: + +```json +{ + "request_id": "req-123", + "backend": "local_qwen", + "prompt": "Summarize this alert" +} +``` + +Structured request: + +```json +{ + "request_id": "req-456", + "requester": "HTTP Analyzer", + "backend": "openai_default", + "messages": [ + {"role": "system", "content": "You are a concise security analyst."}, + {"role": "user", "content": "Analyze this flow."} + ], + "temperature": 0.2, + "max_tokens": 300, + "metadata": {"uid": "C1abc"} +} +``` + +Fields: + +- `request_id`: optional but recommended. Generated if missing. +- `requester`: optional caller name. +- `backend`: optional if `default_backend` is set. +- `prompt`: shortcut for one user message. +- `messages`: list of text messages with roles `system`, `user`, or `assistant`. +- `model`: optional model override for the selected backend. +- `temperature`: optional sampling control. +- `max_tokens`: optional response length limit. +- `metadata`: optional passthrough object echoed back in the response. + +### Response channel + +Channel: `llm_response` + +Success: + +```json +{ + "request_id": "req-456", + "requester": "HTTP Analyzer", + "backend": "openai_default", + "provider": "openai", + "model": "gpt-4o-mini", + "success": true, + "text": "The flow looks like repeated beaconing with stable timing.", + "usage": { + "input_tokens": 120, + "output_tokens": 40, + "total_tokens": 160 + }, + "metadata": {"uid": "C1abc"}, + "ts": 1760000000.0 +} +``` + +Failure: + +```json +{ + "request_id": "req-999", + "backend": "missing_backend", + "success": false, + "error": "Unknown LLM backend requested: missing_backend", + "text": "", + "metadata": {}, + "ts": 1760000000.0 +} +``` + +## Example Integration from Another Module + +Publish: + +```python +import json + +available = self.db.get_available_llm_backends() +backend = available["default_backend"] +if not backend: + return + +request = { + "request_id": "req-123", + "requester": self.name, + "backend": backend, + "prompt": "Summarize this alert in 2 lines.", + "metadata": {"profileid": profileid}, +} +self.db.publish("llm_request", json.dumps(request)) +``` + +Subscribe: + +```python +self.c_llm = self.db.subscribe("llm_response") +self.channels["llm_response"] = self.c_llm +``` + +Read: + +```python +if msg := self.get_msg("llm_response"): + response = json.loads(msg["data"]) + if response["request_id"] == "req-123": + text = response["text"] +``` + +## Operational Notes + +- The module uses one shared response channel, so requesters must match on + `request_id`. +- The first version is text-only. +- If the module is disabled or no valid backends are configured, it will stop + cleanly and no request processing will occur. +- Backend selection is by runtime-ready backend alias, not only by model name. From d035f06e21e3d349cba51304525c63b16cc4903c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:09:42 +0000 Subject: [PATCH 0011/1100] init: add SPDX license identifiers to __init__.py --- modules/llm/__init__.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 modules/llm/__init__.py diff --git a/modules/llm/__init__.py b/modules/llm/__init__.py new file mode 100644 index 0000000000..f436f14183 --- /dev/null +++ b/modules/llm/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only From 18d014f4d9d7cf81ec76dadfb09b6019db168a14 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:09:49 +0000 Subject: [PATCH 0012/1100] feat: implement LLM backend configuration and request handling --- modules/llm/llm.py | 613 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 613 insertions(+) create mode 100644 modules/llm/llm.py diff --git a/modules/llm/llm.py b/modules/llm/llm.py new file mode 100644 index 0000000000..5f71e2a01a --- /dev/null +++ b/modules/llm/llm.py @@ -0,0 +1,613 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +import os +import queue +import threading +import time +import uuid +from dataclasses import dataclass +from typing import Any, Dict, List + +import certifi +import urllib3 + +from slips_files.common.abstracts.imodule import IModule +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.common.slips_utils import utils + + +class LLMConfigurationError(Exception): + pass + + +class LLMRequestError(Exception): + pass + + +@dataclass +class LLMBackendConfig: + alias: str + provider: str + model: str + base_url: str + timeout: int + api_key: str | None = None + anthropic_version: str = "2023-06-01" + + @classmethod + def from_dict(cls, alias: str, data: dict): + if not isinstance(data, dict): + raise LLMConfigurationError( + f"Backend {alias} must be a mapping." + ) + + provider = str(data.get("provider", "")).strip().lower() + if provider not in {"ollama", "openai", "anthropic"}: + raise LLMConfigurationError( + f"Backend {alias} has unsupported provider {provider!r}." + ) + + model = str(data.get("model", "")).strip() + if not model: + raise LLMConfigurationError( + f"Backend {alias} is missing a model." + ) + + timeout = data.get("timeout", 60) + try: + timeout = int(timeout) + except (TypeError, ValueError): + timeout = 60 + timeout = max(1, timeout) + + base_url = str(data.get("base_url", "")).strip() + if not base_url: + base_url = { + "ollama": "http://127.0.0.1:11434", + "openai": "https://api.openai.com/v1", + "anthropic": "https://api.anthropic.com", + }[provider] + base_url = base_url.rstrip("/") + + api_key = cls._resolve_api_key(data) + if provider in {"openai", "anthropic"} and not api_key: + raise LLMConfigurationError( + f"Backend {alias} requires an API key." + ) + + anthropic_version = str( + data.get("anthropic_version", "2023-06-01") + ).strip() + + return cls( + alias=alias, + provider=provider, + model=model, + base_url=base_url, + timeout=timeout, + api_key=api_key, + anthropic_version=anthropic_version, + ) + + @staticmethod + def _resolve_api_key(data: dict) -> str | None: + api_key = data.get("api_key") + if isinstance(api_key, str) and api_key.strip(): + return api_key.strip() + + api_key_env = data.get("api_key_env") + if isinstance(api_key_env, str) and api_key_env.strip(): + env_value = os.environ.get(api_key_env.strip(), "").strip() + if env_value: + return env_value + + api_key_file = data.get("api_key_file") + if isinstance(api_key_file, str) and api_key_file.strip(): + try: + with open(api_key_file.strip(), "r") as f: + return f.read().strip() or None + except OSError: + return None + + return None + + +class LLMBackend: + def __init__(self, config: LLMBackendConfig): + self.config = config + self.http = urllib3.PoolManager( + cert_reqs="CERT_REQUIRED", + ca_certs=certifi.where(), + ) + + def generate(self, request: dict) -> dict: + raise NotImplementedError + + def _request_json( + self, + method: str, + url: str, + payload: dict, + headers: dict | None = None, + ) -> dict: + encoded_payload = json.dumps(payload).encode() + response = self.http.request( + method, + url, + body=encoded_payload, + headers=headers + or {"Content-Type": "application/json"}, + timeout=self.config.timeout, + ) + + try: + decoded = response.data.decode("utf-8") + except UnicodeDecodeError as exc: + raise LLMRequestError(f"Invalid backend response: {exc}") from exc + + if response.status >= 400: + raise LLMRequestError( + f"{self.config.alias} returned HTTP {response.status}: " + f"{decoded[:500]}" + ) + + try: + return json.loads(decoded) + except json.JSONDecodeError as exc: + raise LLMRequestError( + f"Backend {self.config.alias} returned invalid JSON." + ) from exc + + def _build_url(self, endpoint: str) -> str: + base_url = self.config.base_url.rstrip("/") + if endpoint.startswith("/v1/") and base_url.endswith("/v1"): + endpoint = endpoint[3:] + return f"{base_url}{endpoint}" + + @staticmethod + def _normalize_usage(usage: dict | None) -> dict: + usage = usage or {} + return { + "input_tokens": usage.get("prompt_tokens") + or usage.get("input_tokens"), + "output_tokens": usage.get("completion_tokens") + or usage.get("output_tokens"), + "total_tokens": usage.get("total_tokens"), + } + + @staticmethod + def _join_text_blocks(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + text_parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(str(item.get("text", ""))) + return "".join(text_parts) + return str(content or "") + + +class OpenAIBackend(LLMBackend): + def generate(self, request: dict) -> dict: + url = self._build_url("/chat/completions") + payload = { + "model": request.get("model") or self.config.model, + "messages": request["messages"], + } + if request.get("temperature") is not None: + payload["temperature"] = request["temperature"] + if request.get("max_tokens") is not None: + payload["max_tokens"] = request["max_tokens"] + + response = self._request_json( + "POST", + url, + payload, + headers={ + "Authorization": f"Bearer {self.config.api_key}", + "Content-Type": "application/json", + }, + ) + + choices = response.get("choices") or [] + if not choices: + raise LLMRequestError( + f"Backend {self.config.alias} returned no choices." + ) + + message = choices[0].get("message", {}) + return { + "text": self._join_text_blocks(message.get("content", "")), + "usage": self._normalize_usage(response.get("usage")), + "provider": self.config.provider, + "model": response.get("model") or payload["model"], + } + + +class AnthropicBackend(LLMBackend): + def generate(self, request: dict) -> dict: + url = self._build_url("/v1/messages") + system_parts = [] + messages = [] + for message in request["messages"]: + role = message["role"] + content = message["content"] + if role == "system": + system_parts.append(content) + continue + messages.append({"role": role, "content": content}) + + payload = { + "model": request.get("model") or self.config.model, + "messages": messages, + "max_tokens": request.get("max_tokens") or 1024, + } + if system_parts: + payload["system"] = "\n\n".join(system_parts) + if request.get("temperature") is not None: + payload["temperature"] = request["temperature"] + + response = self._request_json( + "POST", + url, + payload, + headers={ + "x-api-key": self.config.api_key, + "anthropic-version": self.config.anthropic_version, + "Content-Type": "application/json", + }, + ) + + content = response.get("content") or [] + return { + "text": self._join_text_blocks(content), + "usage": self._normalize_usage(response.get("usage")), + "provider": self.config.provider, + "model": response.get("model") or payload["model"], + } + + +class OllamaBackend(LLMBackend): + def generate(self, request: dict) -> dict: + url = self._build_url("/api/chat") + payload = { + "model": request.get("model") or self.config.model, + "messages": request["messages"], + "stream": False, + } + options = {} + if request.get("temperature") is not None: + options["temperature"] = request["temperature"] + if request.get("max_tokens") is not None: + options["num_predict"] = request["max_tokens"] + if options: + payload["options"] = options + + response = self._request_json("POST", url, payload) + message = response.get("message", {}) + usage = { + "prompt_tokens": response.get("prompt_eval_count"), + "completion_tokens": response.get("eval_count"), + "total_tokens": None, + } + if usage["prompt_tokens"] is not None and usage["completion_tokens"] is not None: + usage["total_tokens"] = ( + usage["prompt_tokens"] + usage["completion_tokens"] + ) + return { + "text": self._join_text_blocks(message.get("content", "")), + "usage": self._normalize_usage(usage), + "provider": self.config.provider, + "model": response.get("model") or payload["model"], + } + + +class LLM(IModule): + name = "LLM" + description = "Shared LLM access service for other Slips modules" + authors = ["OpenAI Codex"] + + def init(self): + self.c1 = self.db.subscribe(self.db.channels.LLM_REQUEST) + self.channels = { + self.db.channels.LLM_REQUEST: self.c1, + } + self.request_queue: queue.Queue = queue.Queue() + self.worker_stop_event = threading.Event() + self.workers: List[threading.Thread] = [] + self.backends: Dict[str, LLMBackend] = {} + self.failed_backends: Dict[str, str] = {} + self.default_backend = "" + self.worker_threads = 2 + self.queue_size = 100 + self.read_configuration() + + def read_configuration(self): + conf = ( + self.conf + if hasattr(self.conf, "llm_enabled") + else ConfigParser() + ) + self.enabled = conf.llm_enabled() + self.default_backend = conf.llm_default_backend().strip() + self.worker_threads = conf.llm_worker_threads() + self.queue_size = conf.llm_queue_size() + self.request_queue = queue.Queue(maxsize=self.queue_size) + + backend_data = conf.llm_backends() + for alias, data in backend_data.items(): + try: + config = LLMBackendConfig.from_dict(alias, data) + self.backends[alias] = self._create_backend(config) + except LLMConfigurationError as exc: + self.failed_backends[alias] = str(exc) + + def _create_backend(self, config: LLMBackendConfig) -> LLMBackend: + if config.provider == "openai": + return OpenAIBackend(config) + if config.provider == "anthropic": + return AnthropicBackend(config) + return OllamaBackend(config) + + @staticmethod + def _empty_available_backends_registry() -> dict: + return {"default_backend": "", "backends": {}} + + def _get_available_backends_registry(self) -> dict: + available_backends = {} + for alias, backend in self.backends.items(): + available_backends[alias] = { + "provider": backend.config.provider, + "model": backend.config.model, + } + + default_backend = self.default_backend + if default_backend not in available_backends: + default_backend = "" + + return { + "default_backend": default_backend, + "backends": available_backends, + } + + def _store_available_backends_registry(self): + self.db.set_available_llm_backends( + self._get_available_backends_registry() + ) + + def _store_empty_available_backends_registry(self): + self.db.set_available_llm_backends( + self._empty_available_backends_registry() + ) + + def pre_main(self): + utils.drop_root_privs_permanently() + + if not self.enabled: + self._store_empty_available_backends_registry() + self.print("LLM module disabled in config.", 2, 0) + return True + + if self.failed_backends: + for alias, error in self.failed_backends.items(): + self.print( + f"Skipping LLM backend {alias}: {error}", + 0, + 1, + ) + + if not self.backends: + self._store_empty_available_backends_registry() + self.print( + "No valid LLM backends configured. Stopping LLM module.", + 0, + 1, + ) + return True + + if self.default_backend and self.default_backend not in self.backends: + self.print( + f"Default LLM backend {self.default_backend} is not available.", + 0, + 1, + ) + self.default_backend = "" + + for idx in range(self.worker_threads): + worker = threading.Thread( + target=self._worker_loop, + name=f"llm_worker_{idx}", + daemon=True, + ) + worker.start() + self.workers.append(worker) + + self._store_available_backends_registry() + self.print( + f"LLM module ready with backends: {list(self.backends)}", + 2, + 0, + ) + + def main(self): + if msg := self.get_msg(self.db.channels.LLM_REQUEST): + self._enqueue_request(msg) + + def shutdown_gracefully(self): + self.worker_stop_event.set() + for _ in self.workers: + try: + self.request_queue.put_nowait(None) + except queue.Full: + break + for worker in self.workers: + worker.join(timeout=1) + return True + + def _enqueue_request(self, msg: dict): + try: + payload = json.loads(msg["data"]) + except json.JSONDecodeError: + self._publish_response( + { + "request_id": str(uuid.uuid4()), + "success": False, + "error": "Invalid JSON on llm_request channel.", + "text": "", + } + ) + return + + payload["request_id"] = str( + payload.get("request_id") or uuid.uuid4() + ) + + try: + self.request_queue.put_nowait(payload) + except queue.Full: + self._publish_response( + { + "request_id": payload["request_id"], + "requester": payload.get("requester"), + "backend": payload.get("backend"), + "success": False, + "error": "LLM request queue is full.", + "text": "", + "metadata": payload.get("metadata", {}), + } + ) + + def _worker_loop(self): + while not self.worker_stop_event.is_set(): + try: + payload = self.request_queue.get(timeout=0.2) + except queue.Empty: + continue + + if payload is None: + return + + self._handle_request(payload) + + def _handle_request(self, payload: dict): + request_id = payload["request_id"] + requester = payload.get("requester") + metadata = payload.get("metadata", {}) + + try: + request = self._prepare_request(payload) + backend = self.backends[request["backend"]] + result = backend.generate(request) + response = { + "request_id": request_id, + "requester": requester, + "backend": request["backend"], + "provider": result["provider"], + "model": result["model"], + "success": True, + "text": result["text"], + "usage": result["usage"], + "metadata": metadata, + "ts": time.time(), + } + except (LLMRequestError, KeyError, ValueError) as exc: + response = { + "request_id": request_id, + "requester": requester, + "backend": payload.get("backend"), + "success": False, + "error": str(exc), + "text": "", + "metadata": metadata, + "ts": time.time(), + } + except Exception as exc: + response = { + "request_id": request_id, + "requester": requester, + "backend": payload.get("backend"), + "success": False, + "error": f"Unexpected LLM error: {exc}", + "text": "", + "metadata": metadata, + "ts": time.time(), + } + + self._publish_response(response) + + def _prepare_request(self, payload: dict) -> dict: + backend_name = str( + payload.get("backend") or self.default_backend + ).strip() + if not backend_name: + raise LLMRequestError("No backend specified for LLM request.") + if backend_name not in self.backends: + raise LLMRequestError( + f"Unknown LLM backend requested: {backend_name}" + ) + + messages = self._normalize_messages(payload) + request = { + "request_id": payload["request_id"], + "backend": backend_name, + "messages": messages, + "model": payload.get("model"), + "temperature": payload.get("temperature"), + "max_tokens": payload.get("max_tokens"), + } + return request + + def _normalize_messages(self, payload: dict) -> List[dict]: + messages = payload.get("messages") + if not messages: + prompt = payload.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + raise LLMRequestError( + "LLM request needs either messages or prompt." + ) + messages = [{"role": "user", "content": prompt}] + + if not isinstance(messages, list) or not messages: + raise LLMRequestError("LLM messages must be a non-empty list.") + + normalized_messages = [] + for message in messages: + if not isinstance(message, dict): + raise LLMRequestError( + "Each LLM message must be an object." + ) + role = str(message.get("role", "")).strip().lower() + if role not in {"system", "user", "assistant"}: + raise LLMRequestError(f"Invalid LLM role: {role!r}") + + content = self._normalize_message_content(message.get("content")) + if not content: + raise LLMRequestError("LLM message content cannot be empty.") + + normalized_messages.append( + {"role": role, "content": content} + ) + + return normalized_messages + + @staticmethod + def _normalize_message_content(content: Any) -> str: + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text", ""))) + return "".join(parts).strip() + if content is None: + return "" + return str(content).strip() + + def _publish_response(self, payload: dict): + self.db.publish( + self.db.channels.LLM_RESPONSE, + json.dumps(payload), + ) From 619265ef217d5dd60a6e3775148d605d597abb22 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:10:38 +0000 Subject: [PATCH 0013/1100] test: add unit tests for LLM backend configurations and request handling --- tests/unit/modules/llm/test_llm.py | 279 +++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 tests/unit/modules/llm/test_llm.py diff --git a/tests/unit/modules/llm/test_llm.py b/tests/unit/modules/llm/test_llm.py new file mode 100644 index 0000000000..6fff9eb2bb --- /dev/null +++ b/tests/unit/modules/llm/test_llm.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only + +import json +from unittest.mock import Mock, patch + +from modules.llm.llm import ( + AnthropicBackend, + LLMBackendConfig, + OllamaBackend, + OpenAIBackend, +) +from tests.module_factory import ModuleFactory + + +def test_backend_config_reads_api_key_from_env(mocker): + mocker.patch.dict("os.environ", {"OPENAI_API_KEY": "secret-key"}) + + config = LLMBackendConfig.from_dict( + "openai_default", + { + "provider": "openai", + "model": "gpt-4o-mini", + "api_key_env": "OPENAI_API_KEY", + }, + ) + + assert config.api_key == "secret-key" + assert config.base_url == "https://api.openai.com/v1" + + +def test_prepare_request_uses_default_backend_and_prompt(): + llm = ModuleFactory().create_llm_obj() + + request = llm._prepare_request( + {"request_id": "req-1", "prompt": "summarize this"} + ) + + assert request["backend"] == "local_qwen" + assert request["messages"] == [ + {"role": "user", "content": "summarize this"} + ] + + +def test_get_available_backends_registry_has_runtime_ready_backends(): + llm = ModuleFactory().create_llm_obj() + + assert llm._get_available_backends_registry() == { + "default_backend": "local_qwen", + "backends": { + "local_qwen": { + "provider": "ollama", + "model": "qwen2.5:3b", + } + }, + } + + +def test_get_available_backends_registry_blanks_invalid_default(): + llm = ModuleFactory().create_llm_obj() + llm.default_backend = "missing_backend" + + assert llm._get_available_backends_registry() == { + "default_backend": "", + "backends": { + "local_qwen": { + "provider": "ollama", + "model": "qwen2.5:3b", + } + }, + } + + +def test_pre_main_publishes_runtime_ready_registry(): + llm = ModuleFactory().create_llm_obj() + + llm.pre_main() + + llm.db.set_available_llm_backends.assert_called_once_with( + { + "default_backend": "local_qwen", + "backends": { + "local_qwen": { + "provider": "ollama", + "model": "qwen2.5:3b", + } + }, + } + ) + + +def test_pre_main_publishes_empty_registry_when_disabled(): + llm = ModuleFactory().create_llm_obj() + llm.enabled = False + + assert llm.pre_main() is True + llm.db.set_available_llm_backends.assert_called_once_with( + { + "default_backend": "", + "backends": {}, + } + ) + + +def test_pre_main_publishes_empty_registry_when_no_valid_backends(): + llm = ModuleFactory().create_llm_obj() + llm.backends = {} + + assert llm.pre_main() is True + llm.db.set_available_llm_backends.assert_called_once_with( + { + "default_backend": "", + "backends": {}, + } + ) + + +def test_handle_request_publishes_success_response(): + llm = ModuleFactory().create_llm_obj() + llm.backends = { + "local_qwen": Mock( + generate=Mock( + return_value={ + "text": "analysis result", + "usage": { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + }, + "provider": "ollama", + "model": "qwen2.5:3b", + } + ) + ) + } + + llm._handle_request( + { + "request_id": "req-2", + "requester": "HTTP Analyzer", + "prompt": "analyze this flow", + "metadata": {"uid": "C1"}, + } + ) + + channel, payload = llm.db.publish.call_args.args + response = json.loads(payload) + assert channel == "llm_response" + assert response["success"] is True + assert response["request_id"] == "req-2" + assert response["text"] == "analysis result" + assert response["metadata"] == {"uid": "C1"} + + +def test_handle_request_publishes_error_for_unknown_backend(): + llm = ModuleFactory().create_llm_obj() + + llm._handle_request( + { + "request_id": "req-3", + "backend": "missing_backend", + "prompt": "hello", + } + ) + + channel, payload = llm.db.publish.call_args.args + response = json.loads(payload) + assert channel == "llm_response" + assert response["success"] is False + assert "Unknown LLM backend" in response["error"] + + +def test_openai_backend_parses_chat_completion_response(): + config = LLMBackendConfig.from_dict( + "openai_default", + { + "provider": "openai", + "model": "gpt-4o-mini", + "api_key": "secret", + }, + ) + backend = OpenAIBackend(config) + backend._request_json = Mock( + return_value={ + "model": "gpt-4o-mini", + "choices": [ + { + "message": { + "content": "final answer", + } + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 7, + "total_tokens": 19, + }, + } + ) + + response = backend.generate( + { + "messages": [{"role": "user", "content": "Hello"}], + "model": None, + "temperature": None, + "max_tokens": None, + } + ) + + assert response["text"] == "final answer" + assert response["usage"]["total_tokens"] == 19 + + +def test_anthropic_backend_moves_system_messages(): + config = LLMBackendConfig.from_dict( + "claude_default", + { + "provider": "anthropic", + "model": "claude-sonnet-4-5", + "api_key": "secret", + }, + ) + backend = AnthropicBackend(config) + with patch.object(backend, "_request_json") as mock_request: + mock_request.return_value = { + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "anthropic answer"}], + "usage": {"input_tokens": 3, "output_tokens": 4}, + } + response = backend.generate( + { + "messages": [ + {"role": "system", "content": "be terse"}, + {"role": "user", "content": "hello"}, + ], + "model": None, + "temperature": 0.2, + "max_tokens": 128, + } + ) + + sent_payload = mock_request.call_args.args[2] + assert sent_payload["system"] == "be terse" + assert sent_payload["messages"] == [{"role": "user", "content": "hello"}] + assert response["text"] == "anthropic answer" + + +def test_ollama_backend_parses_response(): + config = LLMBackendConfig.from_dict( + "local_qwen", + { + "provider": "ollama", + "model": "qwen2.5:3b", + "base_url": "http://127.0.0.1:11434", + }, + ) + backend = OllamaBackend(config) + backend._request_json = Mock( + return_value={ + "model": "qwen2.5:3b", + "message": {"content": "ollama answer"}, + "prompt_eval_count": 9, + "eval_count": 11, + } + ) + + response = backend.generate( + { + "messages": [{"role": "user", "content": "Hello"}], + "model": None, + "temperature": None, + "max_tokens": None, + } + ) + + assert response["text"] == "ollama answer" + assert response["usage"]["input_tokens"] == 9 + assert response["usage"]["output_tokens"] == 11 + assert response["usage"]["total_tokens"] == 20 From 31b8e8f88cf874e2765a27fb03781837f82a7265 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:10:46 +0000 Subject: [PATCH 0014/1100] feat: add constant for available LLM backends --- slips_files/core/database/redis_db/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/slips_files/core/database/redis_db/constants.py b/slips_files/core/database/redis_db/constants.py index 111a89df17..27348472c6 100644 --- a/slips_files/core/database/redis_db/constants.py +++ b/slips_files/core/database/redis_db/constants.py @@ -107,6 +107,7 @@ class Constants: P2P_PEER_INFO_HASH = "peer_info" FIDES_CACHE_KEY = "fides_cache" FIDES_CACHE_CREATED_SECONDS = "created_seconds" + AVAILABLE_LLM_BACKENDS = "available_llm_backends" class Channels: From 91d64d1e91ac6a3917c4a3913b440ed360611640 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:10:54 +0000 Subject: [PATCH 0015/1100] feat: add methods for managing available LLM backends in RedisDB --- .../core/database/redis_db/database.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/slips_files/core/database/redis_db/database.py b/slips_files/core/database/redis_db/database.py index 90a4ac4314..d951ed43d1 100644 --- a/slips_files/core/database/redis_db/database.py +++ b/slips_files/core/database/redis_db/database.py @@ -665,6 +665,60 @@ def get_enabled_modules(self) -> List[str]: """ return self.r.hkeys(self.constants.PIDS) + @staticmethod + def _empty_available_llm_backends() -> dict: + return {"default_backend": "", "backends": {}} + + def set_available_llm_backends(self, registry: dict): + normalized = self._normalize_available_llm_backends_registry(registry) + self.r.set( + self.constants.AVAILABLE_LLM_BACKENDS, json.dumps(normalized) + ) + + def get_available_llm_backends(self) -> dict: + if registry := self.r.get(self.constants.AVAILABLE_LLM_BACKENDS): + try: + registry = json.loads(registry) + except json.JSONDecodeError: + return self._empty_available_llm_backends() + return self._normalize_available_llm_backends_registry(registry) + + return self._empty_available_llm_backends() + + def _normalize_available_llm_backends_registry(self, registry: dict) -> dict: + if not isinstance(registry, dict): + return self._empty_available_llm_backends() + + backends = registry.get("backends") + if not isinstance(backends, dict): + backends = {} + + normalized_backends = {} + for alias, metadata in backends.items(): + if not isinstance(alias, str) or not alias.strip(): + continue + if not isinstance(metadata, dict): + continue + + provider = str(metadata.get("provider", "")).strip() + model = str(metadata.get("model", "")).strip() + if not provider or not model: + continue + + normalized_backends[alias.strip()] = { + "provider": provider, + "model": model, + } + + default_backend = str(registry.get("default_backend", "")).strip() + if default_backend not in normalized_backends: + default_backend = "" + + return { + "default_backend": default_backend, + "backends": normalized_backends, + } + def get_disabled_modules(self) -> List[str]: if disabled_modules := self.r.hget( self.constants.ANALYSIS, "disabled_modules" From 9075222c8f608ec141830a2f2cbf089b2c067702 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:11:16 +0000 Subject: [PATCH 0016/1100] feat: add tests for managing available LLM backends in database --- .../core/database/test_database.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/unit/slips_files/core/database/test_database.py b/tests/unit/slips_files/core/database/test_database.py index 0e5790dc8c..edd12a9287 100644 --- a/tests/unit/slips_files/core/database/test_database.py +++ b/tests/unit/slips_files/core/database/test_database.py @@ -58,6 +58,50 @@ def test_subscribe(): assert isinstance(db.subscribe("tw_modified"), redis.client.PubSub) +def test_get_available_llm_backends_returns_empty_dict_when_unset(): + db = ModuleFactory().create_db_manager_obj(6379, flush_db=True) + db.r.delete(db.rdb.constants.AVAILABLE_LLM_BACKENDS) + + assert db.get_available_llm_backends() == { + "default_backend": "", + "backends": {}, + } + + +def test_set_and_get_available_llm_backends(): + db = ModuleFactory().create_db_manager_obj(6379, flush_db=True) + + db.set_available_llm_backends( + { + "default_backend": "local_qwen", + "backends": { + "local_qwen": { + "provider": "ollama", + "model": "qwen2.5:3b", + }, + "openai_default": { + "provider": "openai", + "model": "gpt-4o-mini", + }, + }, + } + ) + + assert db.get_available_llm_backends() == { + "default_backend": "local_qwen", + "backends": { + "local_qwen": { + "provider": "ollama", + "model": "qwen2.5:3b", + }, + "openai_default": { + "provider": "openai", + "model": "gpt-4o-mini", + }, + }, + } + + def test_profile_moddule_labels(): """tests set and get_profile_module_label""" db = ModuleFactory().create_db_manager_obj(6379, flush_db=True) From eb252c3bed8e09238fb003781ad914bfc1e1f53c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:31:09 +0000 Subject: [PATCH 0017/1100] feat: enable LLM service module and update backend configuration --- config/slips.yaml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/config/slips.yaml b/config/slips.yaml index ab0525ec1c..6ab47d4745 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -204,29 +204,43 @@ modules: ############################# llm: # Enable the shared LLM service module. - enabled: false + enabled: true - # If a request does not specify a backend, this one is used. + # If a request does not specify a backend alias, this one is used. + # The value must match one of the keys under backends: below. default_backend: local_qwen # Number of worker threads processing LLM requests in parallel. + # All caller modules share the same request queue and the same response + # channel, so modules must correlate replies by request_id. worker_threads: 2 # Maximum number of pending requests kept in memory. queue_size: 100 - # Each backend is a named connection that other modules can select by name. + # Each backend is a named connection that other modules select by alias + # through the request field backend. backends: local_qwen: + # Supported providers: ollama, openai, anthropic provider: ollama + # Default model for this backend alias. Caller modules may override the + # model in an individual request while still using this connection. model: qwen2.5:3b + # Optional. Defaults depend on provider: + # ollama=http://127.0.0.1:11434 + # openai=https://api.openai.com/v1 + # anthropic=https://api.anthropic.com base_url: http://127.0.0.1:11434 + # Optional HTTP timeout in seconds. timeout: 120 openai_default: provider: openai model: gpt-4o-mini base_url: https://api.openai.com/v1 + # Provide one of api_key, api_key_env, or api_key_file for providers + # that require authentication. api_key_env: OPENAI_API_KEY timeout: 60 @@ -235,6 +249,8 @@ llm: model: claude-sonnet-4-5 base_url: https://api.anthropic.com api_key_env: ANTHROPIC_API_KEY + # Optional Anthropic API version header. + anthropic_version: 2023-06-01 timeout: 60 ############################# From d3b6279d918e712a30e797d6e5cd3f22158312c0 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 16:31:19 +0000 Subject: [PATCH 0018/1100] feat: enhance LLM module documentation with configuration and response correlation details --- docs/llm_module.md | 64 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/docs/llm_module.md b/docs/llm_module.md index 399c2f3368..4c3e079ae3 100644 --- a/docs/llm_module.md +++ b/docs/llm_module.md @@ -53,6 +53,26 @@ llm: timeout: 60 ``` +Configuration reference: + +- `enabled`: enables or disables the LLM service module. +- `default_backend`: backend alias used when a request omits `backend`. +- `worker_threads`: number of requests processed in parallel. +- `queue_size`: maximum number of queued requests in memory. +- `backends`: mapping of backend alias to backend configuration. + +Per-backend options: + +- `provider`: one of `ollama`, `openai`, or `anthropic`. +- `model`: default model for that backend alias. +- `base_url`: provider endpoint. If omitted, the provider default is used. +- `timeout`: HTTP timeout in seconds. +- `api_key`: optional inline API key for `openai` or `anthropic`. +- `api_key_env`: optional environment variable containing the API key. +- `api_key_file`: optional file path containing the API key. +- `anthropic_version`: optional Anthropic API version header. Default is + `2023-06-01`. + Each backend is a named connection. Other modules select it by name using the `backend` field in the request. @@ -94,6 +114,31 @@ During startup the helper may temporarily return: Caller modules should retry later if they need LLM access and the registry is still empty. +## How Caller Modules Must Correlate Responses + +The current design uses: + +- one shared request channel: `llm_request` +- one shared response channel: `llm_response` + +This means caller modules must correlate replies themselves. + +Required caller pattern: + +1. Subscribe to `llm_response` during module initialization. +2. Discover runtime-ready backends with + `self.db.get_available_llm_backends()`. +3. Choose a backend alias from the returned registry. +4. Generate a unique `request_id` before publishing. +5. Keep local pending state keyed by `request_id`. +6. Publish the request to `llm_request`. +7. When reading `llm_response`, ignore any response whose `request_id` is not + one of yours. + +If multiple caller modules send requests at the same time, `request_id` is what +separates the replies. `requester` is only a human-readable label and should +not be treated as the primary routing key. + ## Redis Contract ### Request channel @@ -129,7 +174,8 @@ Structured request: Fields: -- `request_id`: optional but recommended. Generated if missing. +- `request_id`: technically optional, but caller modules should always set it. + This is the main correlation key on the shared response channel. - `requester`: optional caller name. - `backend`: optional if `default_backend` is set. - `prompt`: shortcut for one user message. @@ -184,14 +230,18 @@ Publish: ```python import json +import uuid available = self.db.get_available_llm_backends() backend = available["default_backend"] if not backend: return +request_id = f"{self.name}-{uuid.uuid4()}" +pending_requests[request_id] = {"profileid": profileid} + request = { - "request_id": "req-123", + "request_id": request_id, "requester": self.name, "backend": backend, "prompt": "Summarize this alert in 2 lines.", @@ -212,14 +262,20 @@ Read: ```python if msg := self.get_msg("llm_response"): response = json.loads(msg["data"]) - if response["request_id"] == "req-123": - text = response["text"] + request_id = response["request_id"] + if request_id not in pending_requests: + return + + context = pending_requests.pop(request_id) + text = response["text"] ``` ## Operational Notes - The module uses one shared response channel, so requesters must match on `request_id`. +- Caller modules should always generate `request_id` themselves instead of + relying on the service to create one. - The first version is text-only. - If the module is disabled or no valid backends are configured, it will stop cleanly and no request processing will occur. From d462ecd5b5f48b745cbd91d0dfdbdf3a08cb157c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:14:07 +0000 Subject: [PATCH 0019/1100] feat: add pseudo-random regex generation feature to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b12f6074ac..1f66f06f8c 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ Slips key features are: * **Threat Intelligence Updates**: Slips continuously updates threat intelligence files and databases, providing relevant detections as updates occur. * **Integration with External Platforms**: Modules in Slips can look up IP addresses on external platforms such as VirusTotal and RiskIQ. * **Shared LLM Access**: Slips can expose configured LLM backends such as Ollama, OpenAI, and Anthropic to other modules through Redis channels. +* **Pseudo-Random Regex Generation**: Slips can generate and validate pseudo-random regexes for DNS domains, URIs, filenames, TLS SNI, and certificate CN fields for later Zeek-side use. * **Graphical User Interface**: Slips provides a console graphical user interface (Kalipso) and a web interface for displaying detection with graphs and tables. * **Peer-to-Peer (P2P) Module**: Slips includes a complex automatic system to find other peers in the network and share IoC data automatically in a balanced, trusted manner. The P2P module can be enabled as needed. * **Docker Implementation**: Running Slips through Docker on Linux systems is simplified, allowing real-time traffic analysis. From 110cb79f9c2794e51ae1a37fd7300c3e81b0c087 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:14:27 +0000 Subject: [PATCH 0020/1100] feat: add configuration for regex generator module with various parameters --- config/slips.yaml | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/config/slips.yaml b/config/slips.yaml index 6ab47d4745..71261d2ec6 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -253,6 +253,50 @@ llm: anthropic_version: 2023-06-01 timeout: 60 +############################# +regex_generator: + # Enable the shared regex generator module. + enabled: true + + # Wait this many seconds between completed generation cycles. + generation_interval_seconds: 30 + + # Preferred LLM backend aliases for this module. If empty, the module falls + # back to the runtime-ready LLM default backend. + allowed_backends: [local_qwen] + + # Keep the temperature high enough to encourage variation over time. + llm_temperature: 1.2 + + # Token budget for the LLM response. The prompt asks for a tiny JSON object, + # so this should stay relatively small. + llm_max_tokens: 120 + + # Maximum time to wait for a matching llm_response before giving up on the + # current request. + llm_response_timeout_seconds: 300 + + # Number of recent regexes of the same type added to the prompt as "do not + # repeat" history. + recent_history_size: 1 + + # Reject generated regexes longer than this many characters. + max_regex_length: 180 + + # Weighted random choice for the next regex type to generate. + type_weights: + dns_domain: 1 + uri: 1 + filename: 1 + tls_sni: 1 + certificate_cn: 1 + + # Directory that stores the benign corpus DB and the generated regex DB. + store_dir: output/regex_generator + + # Seed the benign corpus DB once with a small built-in sample for each type. + seed_benign_samples: true + ############################# flowmldetection: # This is a module that uses machine learning for detection. From 0c398920551e071addf7a7ac346a9df44de939a7 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:14:34 +0000 Subject: [PATCH 0021/1100] feat: add detailed description for RegexGenerator module in documentation --- docs/detection_modules.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/detection_modules.md b/docs/detection_modules.md index 94915270d3..28d156a67d 100644 --- a/docs/detection_modules.md +++ b/docs/detection_modules.md @@ -128,6 +128,11 @@ tr:nth-child(even) { shared service module that sends prompts to configured OpenAI, Anthropic, or Ollama backends and publishes the replies for other modules ✅ + + RegexGenerator + shared service module that continuously generates pseudo-random regexes, rejects those matching benign corpora, and stores accepted regexes for later modules + ✅ + @@ -141,6 +146,17 @@ configured backend, and publishes the result on `llm_response`. For the full request and response format, backend configuration, and examples, see [LLM Module](llm_module.md). +## Regex Generator Module + +The RegexGenerator module is a shared service for other Slips modules. + +It uses the shared LLM module to generate one regex at a time for DNS domains, +URIs, filenames, TLS SNI, and certificate CN fields, tests that regex against +a benign corpus, and stores accepted regexes in a local SQLite database. + +For the full configuration, acceptance pipeline, and DB helper usage, see +[Regex Generator Module](regex_generator_module.md). + ## HTTPS Anomaly Detection Module For the full technical description of the HTTPS anomaly detector (features, training, adaptation, z-score logic, evidence format, and configuration), see: From 3e8db3180ee4fe6e00b80386278c0fbd9d8d0e8f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:14:41 +0000 Subject: [PATCH 0022/1100] feat: update documentation to include Regex Generator module overview --- docs/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 5c9b913531..f1ee6d185f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,8 @@ This documentation gives an overview how Slips works, how to use it and how to h - **LLM module**. Shared access to configured LLM backends from other Slips modules. See :doc:`LLM module `. +- **Regex Generator module**. Shared service that generates and validates pseudo-random regexes for later Zeek-side use. See :doc:`Regex Generator module `. + - **HTTPS anomaly detection**. Detailed design and behavior of the HTTPS anomaly detector. See :doc:`HTTPS anomaly detection `. - **Architecture**. Internal architecture of Slips (profiles, timewindows), the use of Zeek and connection to Redis. See :doc:`Architecture `. @@ -54,6 +56,7 @@ This documentation gives an overview how Slips works, how to use it and how to h architecture detection_modules llm_module + regex_generator_module https_anomaly_detection flowalerts features From e6bd182aec24b300b5ac9cc4a153fe2352daecf5 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:14:51 +0000 Subject: [PATCH 0023/1100] feat: add documentation for Regex Generator module with configuration and usage details --- docs/regex_generator_module.md | 155 +++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/regex_generator_module.md diff --git a/docs/regex_generator_module.md b/docs/regex_generator_module.md new file mode 100644 index 0000000000..baec2db575 --- /dev/null +++ b/docs/regex_generator_module.md @@ -0,0 +1,155 @@ +# Regex Generator Module + +The `RegexGenerator` module continuously creates one pseudo-random regex at a +time for later Zeek-side use. + +It uses the shared `LLM` module over Redis, validates the generated regex +against a benign corpus, and stores accepted regexes in a dedicated local +SQLite database that later Slips modules can read through `DBManager`. + +## What it does + +The module: + +1. Reads its configuration from `config/slips.yaml` +2. Discovers runtime-ready LLM backends using the shared LLM registry +3. Chooses the next regex type with weighted random selection +4. Sends one generation request over `llm_request` +5. Waits for the matching `llm_response` +6. Validates the regex and tests it against a benign corpus +7. Stores accepted and rejected results in local SQLite + +Supported regex types: + +- `dns_domain` +- `uri` +- `filename` +- `tls_sni` +- `certificate_cn` + +## Configuration + +Example section in `config/slips.yaml`: + +```yaml +regex_generator: + enabled: false + generation_interval_seconds: 5 + allowed_backends: [] + llm_temperature: 1.2 + llm_max_tokens: 220 + llm_response_timeout_seconds: 90 + recent_history_size: 20 + max_regex_length: 180 + type_weights: + dns_domain: 1 + uri: 1 + filename: 1 + tls_sni: 1 + certificate_cn: 1 + store_dir: output/regex_generator + seed_benign_samples: true +``` + +Configuration reference: + +- `enabled`: enables or disables the module. +- `generation_interval_seconds`: delay between completed generation cycles. +- `allowed_backends`: preferred backend aliases for this module. +- `llm_temperature`: generation temperature. Kept high to keep outputs varied. +- `llm_max_tokens`: max tokens for the LLM reply. +- `llm_response_timeout_seconds`: max time to wait for the matching + `llm_response`. +- `recent_history_size`: number of recent same-type regexes included in the + prompt as exclusions. +- `max_regex_length`: hard reject longer regexes. +- `type_weights`: weighted random choice among the supported regex types. +- `store_dir`: directory containing `benign_corpus.sqlite` and + `generated_regexes.sqlite`. +- `seed_benign_samples`: seed the benign DB once with a small built-in sample. + +## LLM request and response usage + +The module uses the existing shared LLM channels only: + +- request channel: `llm_request` +- response channel: `llm_response` + +Each generation request includes: + +- `request_id` +- `requester = "RegexGenerator"` +- `backend` +- `messages` +- `temperature` +- `max_tokens` +- `metadata.regex_type` +- `metadata.prompt_version` +- `metadata.generation_nonce` + +The prompt requires the model to return strict raw JSON: + +```json +{ + "regex": "...", + "rationale": "short text" +} +``` + +V1 keeps one request in flight at a time, so response correlation is simple: +only the matching `request_id` is accepted. + +## Acceptance pipeline + +After the matching `llm_response` arrives, the module: + +1. Parses the returned JSON object +2. Extracts `regex` +3. Rejects empty or malformed results +4. Applies static safety validation +5. Rejects exact duplicates already stored +6. Streams the benign corpus for the selected type +7. Rejects on the first benign match +8. Stores accepted regexes for later use + +Static validation rejects: + +- non-ASCII regexes +- regexes longer than `max_regex_length` +- lookbehind +- backreferences +- unbounded `.*`-style prefix/suffix patterns +- obviously broad patterns such as `.*` and `.+` +- nested wildcard structures that risk catastrophic backtracking +- invalid syntax + +## Benign corpus and bloom filters + +The module creates a benign corpus DB once and can seed it with a small sample +for all five regex types. + +It also builds one in-memory bloom filter per type, but the bloom filters do +not replace the benign corpus scan. They help with exact-string support and +future scale improvements, while the acceptance decision still requires testing +whether the regex matches any benign string. + +The current benign acceptance gate is: + +```sql +SELECT value FROM benign_strings WHERE regex_type = ? +``` + +streamed line by line until the first match. + +## Reading accepted regexes from other modules + +Later modules should not open the SQLite files directly. + +Use the DB helpers: + +```python +self.db.get_generated_regexes(regex_type="dns_domain", limit=100) +self.db.get_generated_regexes_count(regex_type="dns_domain") +``` + +These helpers return accepted regexes by default. From 2e5a1005eed80ff2e7818c6e434fb27da38cc477 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:15:03 +0000 Subject: [PATCH 0024/1100] feat: add LLM module documentation with configuration and usage details --- modules/llm/README.md | 262 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 modules/llm/README.md diff --git a/modules/llm/README.md b/modules/llm/README.md new file mode 100644 index 0000000000..66e16f3137 --- /dev/null +++ b/modules/llm/README.md @@ -0,0 +1,262 @@ +# LLM Module + +The `LLM` module is a shared service for other Slips modules. + +It reads configured backend connections from `config/slips.yaml`, listens for +requests on the Redis channel `llm_request`, sends the prompt to the selected +backend, and publishes the reply on `llm_response`. + +## Supported providers + +- `ollama` +- `openai` +- `anthropic` + +## Configuration + +Example: + +```yaml +llm: + enabled: true + default_backend: local_qwen + worker_threads: 2 + queue_size: 100 + backends: + local_qwen: + provider: ollama + model: qwen2.5:3b + base_url: http://127.0.0.1:11434 + timeout: 120 + openai_default: + provider: openai + model: gpt-4o-mini + base_url: https://api.openai.com/v1 + api_key_env: OPENAI_API_KEY + timeout: 60 + claude_default: + provider: anthropic + model: claude-sonnet-4-5 + base_url: https://api.anthropic.com + api_key_env: ANTHROPIC_API_KEY + timeout: 60 +``` + +Configuration reference: + +- `enabled`: enables or disables the shared LLM service module. +- `default_backend`: backend alias used when a request does not include + `backend`. +- `worker_threads`: number of requests the module can process in parallel. +- `queue_size`: maximum number of pending requests held in memory. +- `backends`: mapping of backend alias to backend connection settings. + +Per-backend options: + +- `provider`: one of `ollama`, `openai`, or `anthropic`. +- `model`: default model used by that backend alias. +- `base_url`: provider endpoint. If omitted, the module uses the provider + default. +- `timeout`: HTTP timeout in seconds. +- `api_key`: optional inline API key for `openai` or `anthropic`. +- `api_key_env`: optional environment variable name holding the API key. +- `api_key_file`: optional file path containing the API key. +- `anthropic_version`: optional Anthropic API version header. Default is + `2023-06-01`. + +Backend aliases are the names that caller modules use in the request field +`backend`. The alias is the stable selector. The `model` field inside a request +is only an optional override for that chosen backend. + +## Request channel + +Channel: `llm_request` + +Minimal request: + +```json +{ + "request_id": "req-123", + "backend": "local_qwen", + "prompt": "Summarize this alert" +} +``` + +Request with explicit messages: + +```json +{ + "request_id": "req-456", + "requester": "Flow Alerts", + "backend": "openai_default", + "messages": [ + {"role": "system", "content": "You are a concise security analyst."}, + {"role": "user", "content": "Explain this incident."} + ], + "temperature": 0.2, + "max_tokens": 300, + "metadata": {"profileid": "profile_192.168.1.10"} +} +``` + +Fields: + +- `request_id`: technically optional, but caller modules should always set it. + This is the primary correlation key on the shared response channel. +- `requester`: optional module name for easier correlation. +- `backend`: optional if `default_backend` is configured. +- `prompt`: shortcut for a single user message. +- `messages`: list of text messages using `system`, `user`, or `assistant`. +- `model`: optional override of the configured model for that backend. +- `temperature`: optional float. +- `max_tokens`: optional integer. +- `metadata`: optional passthrough object returned unchanged in the response. + +## Discovery helper + +Caller modules can discover the runtime-ready backends using: + +```python +available = self.db.get_available_llm_backends() +``` + +The returned shape is: + +```json +{ + "default_backend": "local_qwen", + "backends": { + "local_qwen": { + "provider": "ollama", + "model": "qwen2.5:3b" + }, + "openai_default": { + "provider": "openai", + "model": "gpt-4o-mini" + } + } +} +``` + +If the LLM module is disabled, still starting, or no backend is runtime-ready +yet, the helper returns: + +```json +{ + "default_backend": "", + "backends": {} +} +``` + +Caller modules should retry later instead of treating an empty result as a +permanent failure. + +## How Caller Modules Should Use It + +This module uses one shared request channel and one shared response channel for +all of Slips. + +That means caller modules must follow this pattern: + +1. Subscribe to `llm_response` during module initialization. +2. Call `self.db.get_available_llm_backends()` before choosing a backend. +3. Pick a backend alias from `available["backends"]` or use + `available["default_backend"]`. +4. Generate a unique `request_id` before publishing. +5. Store local context keyed by `request_id` if the response must be matched + back to a flow, profile, or alert. +6. Publish the request to `llm_request`. +7. Read from `llm_response` and ignore responses whose `request_id` is not one + of yours. + +If two modules send requests at the same time, they separate replies by +matching on `request_id`. `requester` is only a human-readable label. It is not +the primary routing key. + +Recommended pattern: + +```python +import json +import uuid + +available = self.db.get_available_llm_backends() +backend = available["default_backend"] +if not backend: + return + +request_id = f"{self.name}-{uuid.uuid4()}" +pending_requests[request_id] = {"profileid": profileid} + +request = { + "request_id": request_id, + "requester": self.name, + "backend": backend, + "prompt": "Summarize this alert in 2 lines.", + "metadata": {"profileid": profileid}, +} +self.db.publish("llm_request", json.dumps(request)) +``` + +Response handling: + +```python +if msg := self.get_msg("llm_response"): + response = json.loads(msg["data"]) + request_id = response["request_id"] + if request_id not in pending_requests: + return + + context = pending_requests.pop(request_id) + text = response["text"] +``` + +Do not rely on the service to generate `request_id` for you. If the caller does +not generate it first, the caller cannot reliably match the reply later. + +## Response channel + +Channel: `llm_response` + +Success response: + +```json +{ + "request_id": "req-456", + "requester": "Flow Alerts", + "backend": "openai_default", + "provider": "openai", + "model": "gpt-4o-mini", + "success": true, + "text": "This alert shows repeated outbound connections...", + "usage": { + "input_tokens": 123, + "output_tokens": 57, + "total_tokens": 180 + }, + "metadata": {"profileid": "profile_192.168.1.10"}, + "ts": 1760000000.0 +} +``` + +Error response: + +```json +{ + "request_id": "req-789", + "backend": "missing_backend", + "success": false, + "error": "Unknown LLM backend requested: missing_backend", + "text": "", + "metadata": {}, + "ts": 1760000000.0 +} +``` + +## Notes + +- The module uses one shared response channel, so requesters must correlate + responses using `request_id`. +- Version 1 is text-only. It accepts plain string prompts and message content. +- Other modules can choose the backend per request by setting `backend`. +- The runtime discovery helper exposes only runtime-ready backends, not every + configured backend. From c087f094ba80d81717b4dbc7eda0cb31ec9667e9 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:15:11 +0000 Subject: [PATCH 0025/1100] feat: add SPDX license headers to regex_generator module --- modules/regex_generator/__init__.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 modules/regex_generator/__init__.py diff --git a/modules/regex_generator/__init__.py b/modules/regex_generator/__init__.py new file mode 100644 index 0000000000..f436f14183 --- /dev/null +++ b/modules/regex_generator/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only From 4ab8604e7b79fa69056c786be9176f78623f89d2 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:16:14 +0000 Subject: [PATCH 0026/1100] feat: add README documentation for Regex Generator module --- modules/regex_generator/README.md | 145 ++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 modules/regex_generator/README.md diff --git a/modules/regex_generator/README.md b/modules/regex_generator/README.md new file mode 100644 index 0000000000..e36122af32 --- /dev/null +++ b/modules/regex_generator/README.md @@ -0,0 +1,145 @@ +# Regex Generator Module + +The `RegexGenerator` module continuously generates one pseudo-random regex at a +time for later Zeek-side matching. + +It uses the shared `LLM` module over the Redis channels `llm_request` and +`llm_response`, validates the generated regex against a benign corpus, and +stores accepted regexes in a local SQLite database that other modules can read +through `DBManager`. + +## Supported regex types + +- `dns_domain` +- `uri` +- `filename` +- `tls_sni` +- `certificate_cn` + +## Configuration + +Example: + +```yaml +regex_generator: + enabled: false + generation_interval_seconds: 5 + allowed_backends: [] + llm_temperature: 1.2 + llm_max_tokens: 220 + llm_response_timeout_seconds: 90 + recent_history_size: 20 + max_regex_length: 180 + type_weights: + dns_domain: 1 + uri: 1 + filename: 1 + tls_sni: 1 + certificate_cn: 1 + store_dir: output/regex_generator + seed_benign_samples: true +``` + +Configuration reference: + +- `enabled`: enables or disables the module. +- `generation_interval_seconds`: delay between completed generation cycles. +- `allowed_backends`: preferred LLM backend aliases for this module. +- `llm_temperature`: generation temperature. Kept high to encourage variation. +- `llm_max_tokens`: max tokens for the LLM reply. +- `llm_response_timeout_seconds`: max time to wait for the matching + `llm_response`. +- `recent_history_size`: number of recent same-type regexes included in the + prompt as "do not repeat" history. +- `max_regex_length`: hard reject longer regexes. +- `type_weights`: weighted random choice among the five regex types. +- `store_dir`: directory containing `benign_corpus.sqlite` and + `generated_regexes.sqlite`. +- `seed_benign_samples`: seed the benign DB once with a small built-in sample. + +## Runtime flow + +Each cycle does this: + +1. Discover runtime-ready LLM backends with + `self.db.get_available_llm_backends()`. +2. Choose one backend alias from `allowed_backends`, or fall back to the LLM + default backend. +3. Choose the next regex type using weighted random selection. +4. Build a fixed prompt for that type, including recent regex history. +5. Publish one request on `llm_request`. +6. Wait for the matching `llm_response` using `request_id`. +7. Extract the regex from the returned JSON. +8. Apply static safety validation. +9. Stream the benign corpus for that type and stop on the first match. +10. Store the result as accepted or rejected. + +V1 keeps only one LLM request in flight at a time. + +## LLM contract + +Request payload: + +```json +{ + "request_id": "RegexGenerator-...", + "requester": "RegexGenerator", + "backend": "local_qwen", + "messages": [...], + "temperature": 1.2, + "max_tokens": 220, + "metadata": { + "regex_type": "dns_domain", + "prompt_version": "regex-generator-v1", + "generation_nonce": "..." + } +} +``` + +The prompt requires the model to return strict raw JSON: + +```json +{ + "regex": "...", + "rationale": "short text" +} +``` + +## Acceptance pipeline + +Static validation rejects: + +- non-ASCII regexes +- regexes longer than `max_regex_length` +- lookbehind +- backreferences +- unbounded prefix/suffix patterns like `.*...*` +- obviously broad patterns like `.*` or `.+` +- nested wildcard structures that risk catastrophic backtracking +- invalid Python/Zeek-compatible syntax + +After static validation, the module scans the benign corpus for the selected +type and rejects the regex on the first benign match. + +## Benign corpus and bloom filters + +The module creates a dedicated benign corpus DB once and can seed it with a +small built-in sample for all supported types. + +It also builds one in-memory bloom filter per type, but the bloom filters do +not replace the benign corpus scan. Bloom filters can answer exact-string +membership questions, while acceptance requires checking whether a regex matches +any benign string. + +## Stored regexes + +Accepted and rejected regexes are stored in `generated_regexes.sqlite`. + +Other modules should access accepted regexes through: + +```python +self.db.get_generated_regexes(regex_type="dns_domain", limit=100) +self.db.get_generated_regexes_count(regex_type="dns_domain") +``` + +These helpers read accepted regexes by default. From 7224356e20c267e123f6a42588683ebf276dd1a1 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:16:28 +0000 Subject: [PATCH 0027/1100] feat: implement RegexGenerator module for dynamic regex generation and validation --- modules/regex_generator/regex_generator.py | 446 +++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 modules/regex_generator/regex_generator.py diff --git a/modules/regex_generator/regex_generator.py b/modules/regex_generator/regex_generator.py new file mode 100644 index 0000000000..75d812c211 --- /dev/null +++ b/modules/regex_generator/regex_generator.py @@ -0,0 +1,446 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +import random +import re +import time +import uuid +from hashlib import sha256 + +from slips_files.common.abstracts.imodule import IModule +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.common.slips_utils import utils +from slips_files.core.database.sqlite_db.regex_generator_db import ( + REGEX_TYPES, + RegexGeneratorStorage, +) + + +PROMPT_VERSION = "regex-generator-v1" +SYSTEM_PROMPT = """ +You generate one Zeek-compatible detection regex for a single field type. + +Rules: +- Output raw JSON only. +- JSON shape: {"regex":"...","rationale":"..."} +- Return exactly one regex and one short rationale. +- Do not wrap the regex in slashes. +- Do not use code fences. +- Use a conservative regex subset portable to Zeek and Python. +- Do not use lookbehind, named groups, backreferences, or inline flags. +- Avoid catastrophic backtracking and nested wildcards. +- The regex must be specific enough to avoid broad benign matching. +- The regex should target suspicious lexical patterns, not exact known IOCs. +- The regex must differ materially from the recent history list. +""".strip() + +TYPE_PROMPTS = { + "dns_domain": """ +Generate a regex for a suspicious DNS domain name. +Focus on lexical patterns often seen in malicious domains: +- long random-looking labels +- encoded-looking subdomains +- suspicious mixtures of letters and digits +- staged subdomains used for tunneling or C2 +Avoid matching common enterprise, CDN, and consumer domains. +The input string is only a domain name, not a URL. +Prefer anchoring with ^ and $ when appropriate. +""".strip(), + "uri": """ +Generate a regex for a suspicious HTTP URI path or full request URI. +Focus on suspicious lexical patterns such as: +- staged payload download paths +- fake update or panel paths +- encoded or obfuscated path segments +- suspicious script/file combinations +Avoid matching ordinary website paths like /, /login, /favicon.ico, health checks, +and typical API routes unless the suspicious lexical combination is strong. +""".strip(), + "filename": """ +Generate a regex for a suspicious filename. +Focus on lexical patterns such as: +- double extensions +- lure words with executable/script extensions +- suspicious archive or installer names +- encoded/random-looking names with risky extensions +Avoid matching ordinary office documents, images, backups, and standard installers. +Prefer anchoring with ^ and $ when appropriate. +""".strip(), + "tls_sni": """ +Generate a regex for a suspicious TLS SNI hostname. +Focus on lexical patterns such as: +- disposable subdomain structures +- random-looking host labels +- deceptive update or login hostnames +- beacon-like hostnames with unusual token composition +Avoid matching major SaaS, CDN, cloud, browser, and operating-system update hosts. +The input string is only the SNI hostname. +Prefer anchoring with ^ and $ when appropriate. +""".strip(), + "certificate_cn": """ +Generate a regex for a suspicious X.509 certificate Common Name. +Focus on lexical patterns such as: +- deceptive hostnames +- awkward token combinations suggesting malware infrastructure +- random or encoded-looking names +- names imitating software update or login services +Avoid matching common public web certificates and ordinary enterprise names. +The input string is only the CN text. +Prefer anchoring with ^ and $ when appropriate. +""".strip(), +} + + +class RegexGenerator(IModule): + name = "RegexGenerator" + description = "Continuously generates and validates pseudo-random regexes" + authors = ["OpenAI Codex"] + + def init(self): + self.c_llm = self.db.subscribe(self.db.channels.LLM_RESPONSE) + self.channels = { + self.db.channels.LLM_RESPONSE: self.c_llm, + } + self.storage = None + self.enabled = False + self.generation_interval_seconds = 5.0 + self.allowed_backends = [] + self.llm_temperature = 1.2 + self.llm_max_tokens = 220 + self.llm_response_timeout_seconds = 90 + self.recent_history_size = 20 + self.max_regex_length = 180 + self.type_weights = {regex_type: 1.0 for regex_type in REGEX_TYPES} + self.pending_request = None + self.next_generation_at = 0.0 + self._rng = random.Random() + self.read_configuration() + + def read_configuration(self): + conf = ( + self.conf + if hasattr(self.conf, "regex_generator_enabled") + else ConfigParser() + ) + self.enabled = conf.regex_generator_enabled() + self.generation_interval_seconds = ( + conf.regex_generator_generation_interval_seconds() + ) + self.allowed_backends = conf.regex_generator_allowed_backends() + self.llm_temperature = conf.regex_generator_llm_temperature() + self.llm_max_tokens = conf.regex_generator_llm_max_tokens() + self.llm_response_timeout_seconds = ( + conf.regex_generator_llm_response_timeout_seconds() + ) + self.recent_history_size = conf.regex_generator_recent_history_size() + self.max_regex_length = conf.regex_generator_max_regex_length() + self.type_weights = conf.regex_generator_type_weights() + + def pre_main(self): + utils.drop_root_privs_permanently() + + if not self.enabled: + self.print("RegexGenerator module disabled in config.", 2, 0) + return True + + self.storage = RegexGeneratorStorage( + self.logger, + self.conf, + self.output_dir, + self.ppid, + ) + self.next_generation_at = time.time() + self.print("RegexGenerator module ready.", 2, 0) + + def shutdown_gracefully(self): + if self.storage: + self.storage.close() + return True + + def main(self): + now = time.time() + if self.pending_request: + self._handle_pending_response(now) + return + + if now < self.next_generation_at: + time.sleep(min(0.5, self.next_generation_at - now)) + return + + available_backends = self.db.get_available_llm_backends() + backend = self._select_backend(available_backends) + if not backend: + self.print( + "RegexGenerator is waiting for a runtime-ready LLM backend.", + 2, + 0, + ) + self.next_generation_at = now + self.generation_interval_seconds + time.sleep(min(0.5, self.generation_interval_seconds)) + return + + regex_type = self._choose_regex_type() + self._send_generation_request(regex_type, backend) + + def _select_backend(self, available_backends: dict) -> str: + available = available_backends.get("backends", {}) + if not available: + return "" + + for backend in self.allowed_backends: + if backend in available: + return backend + + default_backend = available_backends.get("default_backend", "") + if default_backend in available: + return default_backend + + if self.allowed_backends: + return "" + + return sorted(available)[0] + + def _choose_regex_type(self) -> str: + regex_types = list(self.type_weights) + weights = [self.type_weights[regex_type] for regex_type in regex_types] + return self._rng.choices(regex_types, weights=weights, k=1)[0] + + def _send_generation_request(self, regex_type: str, backend: str): + request_id = f"{self.name}-{uuid.uuid4()}" + generation_nonce = str(uuid.uuid4()) + request = { + "request_id": request_id, + "requester": self.name, + "backend": backend, + "messages": self._build_prompt_messages(regex_type, generation_nonce), + "temperature": self.llm_temperature, + "max_tokens": self.llm_max_tokens, + "metadata": { + "regex_type": regex_type, + "prompt_version": PROMPT_VERSION, + "generation_nonce": generation_nonce, + }, + } + self.db.publish( + self.db.channels.LLM_REQUEST, + json.dumps(request), + ) + self.pending_request = { + "request_id": request_id, + "regex_type": regex_type, + "backend": backend, + "sent_at": time.time(), + "generation_nonce": generation_nonce, + } + + def _build_prompt_messages( + self, + regex_type: str, + generation_nonce: str, + ) -> list: + history = self.storage.get_recent_history( + regex_type, self.recent_history_size + ) + benign_examples = self.storage.get_benign_examples(regex_type, limit=5) + + history_lines = [] + for item in history: + history_lines.append( + f'- status={item["status"]} regex={item["regex"]}' + ) + history_text = "\n".join(history_lines) or "- none" + benign_text = "\n".join(f"- {value}" for value in benign_examples) + benign_text = benign_text or "- none" + + user_prompt = ( + f"Regex type: {regex_type}\n" + f"Prompt version: {PROMPT_VERSION}\n" + f"Generation nonce: {generation_nonce}\n\n" + f"{TYPE_PROMPTS[regex_type]}\n\n" + "Recent regex history that must not be repeated or trivially rewritten:\n" + f"{history_text}\n\n" + "Common benign examples that must stay unmatched if reasonably possible:\n" + f"{benign_text}\n\n" + "Return strict raw JSON only." + ) + return [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ] + + def _handle_pending_response(self, now: float): + if now - self.pending_request["sent_at"] > self.llm_response_timeout_seconds: + self.print( + "RegexGenerator request timed out waiting for llm_response.", + 0, + 1, + ) + self.pending_request = None + self.next_generation_at = now + self.generation_interval_seconds + return + + if not (msg := self.get_msg(self.db.channels.LLM_RESPONSE)): + time.sleep(0.1) + return + + try: + response = json.loads(msg["data"]) + except (TypeError, json.JSONDecodeError): + return + + if response.get("request_id") != self.pending_request["request_id"]: + return + + self._finalize_request(response) + self.pending_request = None + self.next_generation_at = time.time() + self.generation_interval_seconds + + def _finalize_request(self, response: dict): + if not response.get("success"): + self.print( + f"RegexGenerator LLM error: {response.get('error', 'unknown')}", + 0, + 1, + ) + return + + llm_text = response.get("text", "") + regex, rejection_reason = self._extract_regex_from_llm_text(llm_text) + if rejection_reason: + self.print( + f"RegexGenerator rejected malformed LLM response: {rejection_reason}", + 0, + 1, + ) + return + + record = { + "regex_type": self.pending_request["regex_type"], + "regex": regex, + "regex_hash": self._hash_regex(regex), + "backend_alias": self.pending_request["backend"], + "provider": response.get("provider"), + "model": response.get("model"), + "temperature": self.llm_temperature, + "prompt_version": PROMPT_VERSION, + "request_id": self.pending_request["request_id"], + "created_at": time.time(), + } + self._validate_and_store_regex(record) + + def _extract_regex_from_llm_text(self, llm_text: str) -> tuple[str, str | None]: + try: + payload = json.loads(llm_text) + except (TypeError, json.JSONDecodeError): + return "", "invalid_json" + + if not isinstance(payload, dict): + return "", "response_not_object" + + regex = payload.get("regex") + if not isinstance(regex, str) or not regex.strip(): + return "", "missing_regex" + + return regex.strip(), None + + @staticmethod + def _hash_regex(regex: str) -> str: + return sha256(regex.encode("utf-8")).hexdigest() + + def _validate_and_store_regex(self, record: dict): + validation_error = self._validate_regex(record["regex"]) + if validation_error: + self._store_rejected_regex(record, validation_error) + return + + if self.storage.get_existing_generated_regex(record["regex_hash"]): + self.print( + f"RegexGenerator rejected duplicate regex: {record['regex']}", + 2, + 0, + ) + return + + compiled_regex = re.compile(record["regex"]) + matched_benign = self._find_matching_benign_value( + record["regex_type"], + compiled_regex, + ) + if matched_benign: + self._store_rejected_regex( + record, + "matched_benign_data", + matched_benign_value=matched_benign, + ) + return + + record["status"] = "accepted" + record["rejection_reason"] = None + record["matched_benign_value"] = None + self.storage.store_generated_regex(record) + + def _store_rejected_regex( + self, + record: dict, + rejection_reason: str, + matched_benign_value: str | None = None, + ): + record["status"] = "rejected" + record["rejection_reason"] = rejection_reason + record["matched_benign_value"] = matched_benign_value + self.storage.store_generated_regex(record) + + def _validate_regex(self, regex: str) -> str | None: + try: + regex.encode("ascii") + except UnicodeEncodeError: + return "non_ascii_regex" + + if len(regex) > self.max_regex_length: + return "regex_too_long" + + if regex in {".*", ".+", "^.*$", "^.+$"}: + return "regex_too_broad" + + if "(?<=" in regex or "(? bool: + stripped_regex = regex.strip("^$()") + if "|" not in stripped_regex: + return False + + parts = [part.strip("()[]{}?+*.^$") for part in stripped_regex.split("|")] + parts = [part for part in parts if part] + if len(parts) < 4: + return False + return all(len(part) <= 2 for part in parts) + + def _find_matching_benign_value(self, regex_type: str, compiled_regex) -> str | None: + for value in self.storage.iter_benign_strings(regex_type): + if compiled_regex.search(value): + return value + return None From 6f861bc7c202a6c4e417ec886b0ff85c41d42c62 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:16:37 +0000 Subject: [PATCH 0028/1100] feat: add configuration methods for regex generator settings in ConfigParser --- slips_files/common/parsers/config_parser.py | 124 ++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index b79e0456c0..5e065aa8f4 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -726,6 +726,127 @@ def llm_backends(self) -> dict: backends = self.read_configuration("llm", "backends", {}) return backends if isinstance(backends, dict) else {} + def regex_generator_enabled(self) -> bool: + value = self.read_configuration("regex_generator", "enabled", False) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") + + def regex_generator_generation_interval_seconds(self) -> float: + value = self.read_configuration( + "regex_generator", "generation_interval_seconds", 5 + ) + try: + value = float(value) + except (TypeError, ValueError): + value = 5 + return max(0.1, value) + + def regex_generator_allowed_backends(self) -> list: + value = self.read_configuration( + "regex_generator", "allowed_backends", [] + ) + if not isinstance(value, list): + return [] + return [ + str(backend).strip() + for backend in value + if str(backend).strip() + ] + + def regex_generator_llm_temperature(self) -> float: + value = self.read_configuration( + "regex_generator", "llm_temperature", 1.2 + ) + try: + value = float(value) + except (TypeError, ValueError): + value = 1.2 + return max(0.0, value) + + def regex_generator_llm_max_tokens(self) -> int: + value = self.read_configuration( + "regex_generator", "llm_max_tokens", 220 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 220 + return max(1, value) + + def regex_generator_llm_response_timeout_seconds(self) -> int: + value = self.read_configuration( + "regex_generator", "llm_response_timeout_seconds", 90 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 90 + return max(1, value) + + def regex_generator_recent_history_size(self) -> int: + value = self.read_configuration( + "regex_generator", "recent_history_size", 20 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 20 + return max(0, value) + + def regex_generator_max_regex_length(self) -> int: + value = self.read_configuration( + "regex_generator", "max_regex_length", 180 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 180 + return max(1, value) + + def regex_generator_type_weights(self) -> dict: + default_weights = { + "dns_domain": 1, + "uri": 1, + "filename": 1, + "tls_sni": 1, + "certificate_cn": 1, + } + value = self.read_configuration( + "regex_generator", "type_weights", default_weights + ) + if not isinstance(value, dict): + return default_weights + + sanitized_weights = {} + for regex_type, default_weight in default_weights.items(): + raw_weight = value.get(regex_type, default_weight) + try: + raw_weight = float(raw_weight) + except (TypeError, ValueError): + raw_weight = default_weight + sanitized_weights[regex_type] = max(0.0, raw_weight) + + if not any(sanitized_weights.values()): + return default_weights + return sanitized_weights + + def regex_generator_store_dir(self) -> str: + value = self.read_configuration( + "regex_generator", "store_dir", "output/regex_generator" + ) + if not isinstance(value, str) or not value.strip(): + return "output/regex_generator" + return value.strip() + + def regex_generator_seed_benign_samples(self) -> bool: + value = self.read_configuration( + "regex_generator", "seed_benign_samples", True + ) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") + def analysis_direction(self): """ Controls which traffic flows are processed and analyzed by SLIPS. @@ -936,6 +1057,9 @@ def get_disabled_modules(self, input_type: str) -> list: if not self.llm_enabled(): to_ignore.append("llm") + if not self.regex_generator_enabled(): + to_ignore.append("regex_generator") + return to_ignore def get_cpu_profiler_enable(self): From 334bb5927c7118955e8a12313ff990461ef61a4e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:16:49 +0000 Subject: [PATCH 0029/1100] feat: add regex generator storage management to DBManager --- slips_files/core/database/database_manager.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/slips_files/core/database/database_manager.py b/slips_files/core/database/database_manager.py index 22c555a0d4..ee892b3a64 100644 --- a/slips_files/core/database/database_manager.py +++ b/slips_files/core/database/database_manager.py @@ -44,8 +44,10 @@ def __init__( self.conf = conf self.output_dir = output_dir self.redis_port = redis_port + self.main_pid = main_pid self.logger = logger self.printer = Printer(self.logger, self.name) + self.regex_generator_storage = None # only the main process should ever flush the Redis DB. to avoid # children overwriting values set at the very start of slips if os.getpid() != main_pid: @@ -1085,6 +1087,30 @@ def get_the_other_ip_version(self, *args, **kwargs): def get_separator(self): return self.rdb.separator + def _get_regex_generator_storage(self): + if self.regex_generator_storage is None: + from slips_files.core.database.sqlite_db.regex_generator_db import ( + RegexGeneratorStorage, + ) + + self.regex_generator_storage = RegexGeneratorStorage( + self.logger, + self.conf, + self.output_dir, + self.main_pid, + ) + return self.regex_generator_storage + + def get_generated_regexes(self, *args, **kwargs): + return self._get_regex_generator_storage().get_generated_regexes( + *args, **kwargs + ) + + def get_generated_regexes_count(self, *args, **kwargs): + return self._get_regex_generator_storage().get_generated_regexes_count( + *args, **kwargs + ) + def get_icmp_attack_info_to_single_host(self, *args, **kwargs): return self.rdb.get_icmp_attack_info_to_single_host(*args, **kwargs) @@ -1179,6 +1205,8 @@ def close_sqlite(self, *args, **kwargs): # when stopping the daemon using -S, slips doesn't start the sqlite db if self.sqlite: self.sqlite.close(*args, **kwargs) + if self.regex_generator_storage: + self.regex_generator_storage.close() def close_all_dbs(self): self.rdb.r.close() From c370808e73001ad787910eb624528b02a7336f60 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:17:05 +0000 Subject: [PATCH 0030/1100] feat: add regex generator object creation method in ModuleFactory --- tests/module_factory.py | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/module_factory.py b/tests/module_factory.py index 1a0815ff5c..a113338fb1 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -73,6 +73,10 @@ def create_db_manager_obj( conf.client_ips = Mock(return_value=[]) conf.use_local_p2p = Mock(return_value=False) conf.width = Mock(return_value=3600) + conf.regex_generator_store_dir = Mock( + return_value=os.path.join(output_dir, "regex_generator") + ) + conf.regex_generator_seed_benign_samples = Mock(return_value=True) with ( # to prevent config/redis.conf from being overwritten @@ -169,6 +173,47 @@ def create_llm_obj(self, mock_db): llm.print = Mock() return llm + @patch(MODULE_DB_MANAGER, name="mock_db") + def create_regex_generator_obj(self, mock_db, store_dir="dummy_output_dir/regex_generator"): + from modules.regex_generator.regex_generator import RegexGenerator + + conf = Mock() + conf.regex_generator_enabled = Mock(return_value=True) + conf.regex_generator_generation_interval_seconds = Mock(return_value=5) + conf.regex_generator_allowed_backends = Mock(return_value=["local_qwen"]) + conf.regex_generator_llm_temperature = Mock(return_value=1.2) + conf.regex_generator_llm_max_tokens = Mock(return_value=220) + conf.regex_generator_llm_response_timeout_seconds = Mock(return_value=90) + conf.regex_generator_recent_history_size = Mock(return_value=20) + conf.regex_generator_max_regex_length = Mock(return_value=180) + conf.regex_generator_type_weights = Mock( + return_value={ + "dns_domain": 1, + "uri": 1, + "filename": 1, + "tls_sni": 1, + "certificate_cn": 1, + } + ) + conf.regex_generator_store_dir = Mock(return_value=store_dir) + conf.regex_generator_seed_benign_samples = Mock(return_value=True) + + regex_generator = RegexGenerator( + logger=self.logger, + output_dir="dummy_output_dir", + redis_port=6379, + termination_event=Mock(), + slips_args=Mock(), + conf=conf, + ppid=12345, + bloom_filters_manager=Mock(), + ) + regex_generator.db.channels.LLM_REQUEST = "llm_request" + regex_generator.db.channels.LLM_RESPONSE = "llm_response" + regex_generator.channels = {"llm_response": regex_generator.c_llm} + regex_generator.print = Mock() + return regex_generator + @patch(MODULE_DB_MANAGER, name="mock_db") def create_fides_module_obj(self, mock_db): from modules.fidesModule.fidesModule import FidesModule From bad2b99d30da657f56064d237c641a3e426c4d59 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Tue, 17 Mar 2026 17:17:17 +0000 Subject: [PATCH 0031/1100] feat: add test for generated regexes storage and retrieval in DBManager --- .../core/database/test_database.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/unit/slips_files/core/database/test_database.py b/tests/unit/slips_files/core/database/test_database.py index edd12a9287..38567547fb 100644 --- a/tests/unit/slips_files/core/database/test_database.py +++ b/tests/unit/slips_files/core/database/test_database.py @@ -102,6 +102,58 @@ def test_set_and_get_available_llm_backends(): } +def test_get_generated_regexes_and_count(tmp_path): + db = ModuleFactory().create_db_manager_obj( + 6379, + output_dir=str(tmp_path / "output"), + flush_db=True, + ) + db.conf.regex_generator_store_dir = Mock( + return_value=str(tmp_path / "regex_generator") + ) + db.conf.regex_generator_seed_benign_samples = Mock(return_value=False) + + storage = db._get_regex_generator_storage() + storage.store_generated_regex( + { + "regex_type": "dns_domain", + "regex": r"^xqz[a-z0-9]{8,12}\.invalid$", + "regex_hash": "hash-1", + "status": "accepted", + "rejection_reason": None, + "matched_benign_value": None, + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": "regex-generator-v1", + "request_id": "req-1", + "created_at": 1.0, + } + ) + + regexes = db.get_generated_regexes("dns_domain") + assert regexes == [ + { + "id": regexes[0]["id"], + "regex_type": "dns_domain", + "regex": r"^xqz[a-z0-9]{8,12}\.invalid$", + "regex_hash": "hash-1", + "status": "accepted", + "rejection_reason": None, + "matched_benign_value": None, + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": "regex-generator-v1", + "request_id": "req-1", + "created_at": 1.0, + } + ] + assert db.get_generated_regexes_count("dns_domain") == 1 + + def test_profile_moddule_labels(): """tests set and get_profile_module_label""" db = ModuleFactory().create_db_manager_obj(6379, flush_db=True) From d9885fe4aba04741f8db534fa023eab995a335cd Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:08:04 +0000 Subject: [PATCH 0032/1100] feat: update regex_generator configuration for improved logging and performance --- config/slips.yaml | 56 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/config/slips.yaml b/config/slips.yaml index 71261d2ec6..f7df8e06e6 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -227,11 +227,8 @@ llm: # Default model for this backend alias. Caller modules may override the # model in an individual request while still using this connection. model: qwen2.5:3b - # Optional. Defaults depend on provider: - # ollama=http://127.0.0.1:11434 - # openai=https://api.openai.com/v1 - # anthropic=https://api.anthropic.com - base_url: http://127.0.0.1:11434 + #base_url: http://127.0.0.1:11434 + base_url: http://147.32.83.61:11434 # Optional HTTP timeout in seconds. timeout: 120 @@ -258,8 +255,14 @@ regex_generator: # Enable the shared regex generator module. enabled: true + # Create output/regex_generator.log with detailed internal progress logs. + # This file follows the global parameters.rotation / rotation_period policy. + create_log_file: true + # Wait this many seconds between completed generation cycles. - generation_interval_seconds: 30 + # Set to 0 to start the next cycle immediately after the previous one + # finishes and the module is ready again. + generation_interval_seconds: 0 # Preferred LLM backend aliases for this module. If empty, the module falls # back to the runtime-ready LLM default backend. @@ -268,21 +271,27 @@ regex_generator: # Keep the temperature high enough to encourage variation over time. llm_temperature: 1.2 - # Token budget for the LLM response. The prompt asks for a tiny JSON object, - # so this should stay relatively small. - llm_max_tokens: 120 + # Token budget for the LLM response. The prompt asks for one regex line only, + # so this should stay very small. + llm_max_tokens: 80 - # Maximum time to wait for a matching llm_response before giving up on the - # current request. + # Soft warning threshold in seconds while waiting for the matching + # llm_response. The module keeps waiting after this and only logs that the + # LLM is slow. Set to 0 to disable the warning. llm_response_timeout_seconds: 300 - # Number of recent regexes of the same type added to the prompt as "do not - # repeat" history. - recent_history_size: 1 + # Prompt history is not sent to the LLM. Repetition is checked locally with a + # bloom filter plus exact DB lookup, so keep this at 0. + recent_history_size: 0 # Reject generated regexes longer than this many characters. max_regex_length: 180 + # Hard wall-clock timeout for local regex validation and benign-corpus + # matching. This prevents one pathological regex from freezing the module. + # Set to 0 to disable the timeout. + regex_validation_timeout_seconds: 2 + # Weighted random choice for the next regex type to generate. type_weights: dns_domain: 1 @@ -292,9 +301,28 @@ regex_generator: certificate_cn: 1 # Directory that stores the benign corpus DB and the generated regex DB. + # Absolute paths are used as-is. Relative paths are resolved inside the + # output directory of the current Slips run. store_dir: output/regex_generator + # Optional stable absolute directory for the regex SQLite files. + # If set, it takes precedence over store_dir and lets the generator reuse + # the same DBs across many Slips restarts. + persistent_store_dir: "~/StratosphereLinuxIPS/databases/regex_store" + + # Persist rejected regexes in generated_regexes.sqlite. + # Leave this disabled unless you need audit/debug history, otherwise disk + # usage will grow with low-value rejected rows. + store_rejected_regexes: false + + # If rejected regex persistence is enabled, keep at most this many rejected + # rows and prune older ones first. Set to 0 for unlimited retention. + max_stored_rejected_regexes: 10000 + # Seed the benign corpus DB once with a small built-in sample for each type. + # The module also imports domain entries from the configured local whitelist + # file into the benign corpus on each run for dns_domain, tls_sni, and + # certificate_cn checks. seed_benign_samples: true ############################# From 1e5df6d898b03d59869e4fde13667f95e198e268 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:09:33 +0000 Subject: [PATCH 0033/1100] feat: enhance regex generator configuration with new options and improved logging --- docs/regex_generator_module.md | 156 ++++++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 24 deletions(-) diff --git a/docs/regex_generator_module.md b/docs/regex_generator_module.md index baec2db575..dd8a83bc79 100644 --- a/docs/regex_generator_module.md +++ b/docs/regex_generator_module.md @@ -17,7 +17,8 @@ The module: 4. Sends one generation request over `llm_request` 5. Waits for the matching `llm_response` 6. Validates the regex and tests it against a benign corpus -7. Stores accepted and rejected results in local SQLite +7. Stores accepted results in local SQLite. Rejected results are only persisted + if explicitly enabled. Supported regex types: @@ -34,13 +35,15 @@ Example section in `config/slips.yaml`: ```yaml regex_generator: enabled: false + create_log_file: false generation_interval_seconds: 5 allowed_backends: [] llm_temperature: 1.2 - llm_max_tokens: 220 + llm_max_tokens: 80 llm_response_timeout_seconds: 90 - recent_history_size: 20 + recent_history_size: 0 max_regex_length: 180 + regex_validation_timeout_seconds: 2 type_weights: dns_domain: 1 uri: 1 @@ -48,24 +51,46 @@ regex_generator: tls_sni: 1 certificate_cn: 1 store_dir: output/regex_generator + persistent_store_dir: "" + store_rejected_regexes: false + max_stored_rejected_regexes: 10000 seed_benign_samples: true ``` Configuration reference: - `enabled`: enables or disables the module. +- `create_log_file`: creates `output/regex_generator.log` with detailed module + progress messages. This file rotates on the same global + `parameters.rotation` / `parameters.rotation_period` schedule used by the + current Slips run. - `generation_interval_seconds`: delay between completed generation cycles. + Set `0` to start the next cycle immediately after the previous one finishes. - `allowed_backends`: preferred backend aliases for this module. - `llm_temperature`: generation temperature. Kept high to keep outputs varied. -- `llm_max_tokens`: max tokens for the LLM reply. -- `llm_response_timeout_seconds`: max time to wait for the matching - `llm_response`. -- `recent_history_size`: number of recent same-type regexes included in the - prompt as exclusions. +- `llm_max_tokens`: max tokens for the LLM reply. The module asks for one regex + line only, so keep this small. +- `llm_response_timeout_seconds`: soft warning threshold while waiting for the + matching `llm_response`. The module keeps waiting after this. Set `0` to + disable the warning. +- `recent_history_size`: compatibility knob kept at `0`. Prompt history is not + sent to the LLM; repetition is checked locally. - `max_regex_length`: hard reject longer regexes. +- `regex_validation_timeout_seconds`: hard wall-clock timeout for local regex + validation and benign-corpus matching. This prevents one pathological regex + from freezing the module. Set `0` to disable it. - `type_weights`: weighted random choice among the supported regex types. - `store_dir`: directory containing `benign_corpus.sqlite` and - `generated_regexes.sqlite`. + `generated_regexes.sqlite`. Absolute paths are used as-is. Relative paths are + resolved inside the current Slips run output directory. The default + `output/regex_generator` therefore becomes `/regex_generator`. +- `persistent_store_dir`: stable absolute directory for the regex SQLite files. + If set, it takes precedence over `store_dir` and lets the generator reuse + the same DBs across many Slips restarts. +- `store_rejected_regexes`: stores rejected regexes in SQLite for audit/debug + purposes. Default `false` so discarded candidates do not fill the disk. +- `max_stored_rejected_regexes`: retention cap for rejected rows when + `store_rejected_regexes` is enabled. Set `0` for unlimited retention. - `seed_benign_samples`: seed the benign DB once with a small built-in sample. ## LLM request and response usage @@ -87,27 +112,49 @@ Each generation request includes: - `metadata.prompt_version` - `metadata.generation_nonce` -The prompt requires the model to return strict raw JSON: - -```json -{ - "regex": "...", - "rationale": "short text" -} -``` +The prompt requires the model to return exactly one regex line. No JSON, +explanation, or code fences. The parser still accepts JSON-shaped replies as a +fallback for compatibility, but the active prompt is raw-regex only. V1 keeps one request in flight at a time, so response correlation is simple: only the matching `request_id` is accepted. +If the local LLM is slow, the module keeps waiting and only logs a warning +after `llm_response_timeout_seconds`. + +If `create_log_file` is enabled, the module also writes detailed progress logs +to: + +```text +output/regex_generator.log +``` + +This file records: + +- selected regex type +- selected backend +- published `llm_request` `request_id` +- slow-wait warnings while the LLM is still working +- accepted regexes +- rejected regexes and rejection reasons + +Accepted regexes are always stored in: + +```text +/regex_generator/generated_regexes.sqlite +``` + +Rejected regexes are tracked in memory during the current run to reduce cheap +repeats, but they are not stored on disk unless `store_rejected_regexes` is +enabled. ## Acceptance pipeline After the matching `llm_response` arrives, the module: -1. Parses the returned JSON object -2. Extracts `regex` +1. Extracts one regex line from the LLM reply 3. Rejects empty or malformed results 4. Applies static safety validation -5. Rejects exact duplicates already stored +5. Checks local duplicates with a bloom filter and exact SQLite lookup 6. Streams the benign corpus for the selected type 7. Rejects on the first benign match 8. Stores accepted regexes for later use @@ -128,10 +175,19 @@ Static validation rejects: The module creates a benign corpus DB once and can seed it with a small sample for all five regex types. -It also builds one in-memory bloom filter per type, but the bloom filters do -not replace the benign corpus scan. They help with exact-string support and -future scale improvements, while the acceptance decision still requires testing -whether the regex matches any benign string. +On each run, it also imports domain entries from the configured Slips local +whitelist file into the benign corpus for the matching domain-like regex +types: + +- `dns_domain` +- `tls_sni` +- `certificate_cn` + +It also builds one in-memory bloom filter per benign type and one bloom filter +for generated regex hashes, but these do not replace the benign corpus scan. +They help with exact membership checks and future scale improvements, while the +acceptance decision still requires testing whether the regex matches any benign +string. The current benign acceptance gate is: @@ -153,3 +209,55 @@ self.db.get_generated_regexes_count(regex_type="dns_domain") ``` These helpers return accepted regexes by default. + +## Offline coverage report + +There is also a standalone offline report script for estimating how much the +accepted regexes cover several reference populations for a given Slips run. + +Example: + +```bash +./venv/bin/python scripts/regex_coverage_report.py \ + --run-output-dir output/eno1_2026-03-18_10:00:30 \ + --redis-port 6379 +``` + +By default, large populations are sampled so the script finishes in practical +time. It prints terminal progress while it runs, for example: + +```text +[37/752] type=dns_domain comparisons=742257/8547616 regex=... +``` + +If you want the exhaustive run for research, use: + +```bash +./venv/bin/python scripts/regex_coverage_report.py \ + --run-output-dir /path/to/regex_store \ + --redis-port 23456 \ + --ti-cache-port 6379 \ + --ti-cache-db 1 \ + --full-scan +``` + +Useful knobs: + +- `--full-scan`: disable sampling and scan the full populations. +- `--max-population-size`: sample cap for each population/type in estimate mode. +- `--match-timeout-seconds`: per-regex/per-population timeout guard. + +The script writes: + +- `regex_generator_coverage_report.html` +- `regex_generator_coverage_report.json` + +inside the selected run output directory. + +The estimate is based on: + +- the RegexGenerator benign corpus DB +- TI-derived malicious reference strings from Redis and TI cache files +- observed traffic strings from Zeek logs or `flows.sqlite` + +This is an offline report only. It does not run continuously inside Slips. From 03944be1233df682eda68bf322632481735cd02d Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:09:47 +0000 Subject: [PATCH 0034/1100] feat: update regex generator configuration with new parameters for logging and storage management --- modules/regex_generator/README.md | 166 +++++++++++++++++++++++++----- 1 file changed, 138 insertions(+), 28 deletions(-) diff --git a/modules/regex_generator/README.md b/modules/regex_generator/README.md index e36122af32..a5bd6d2ee3 100644 --- a/modules/regex_generator/README.md +++ b/modules/regex_generator/README.md @@ -23,13 +23,15 @@ Example: ```yaml regex_generator: enabled: false + create_log_file: false generation_interval_seconds: 5 allowed_backends: [] llm_temperature: 1.2 - llm_max_tokens: 220 + llm_max_tokens: 80 llm_response_timeout_seconds: 90 - recent_history_size: 20 + recent_history_size: 0 max_regex_length: 180 + regex_validation_timeout_seconds: 2 type_weights: dns_domain: 1 uri: 1 @@ -37,24 +39,46 @@ regex_generator: tls_sni: 1 certificate_cn: 1 store_dir: output/regex_generator + persistent_store_dir: "" + store_rejected_regexes: false + max_stored_rejected_regexes: 10000 seed_benign_samples: true ``` Configuration reference: - `enabled`: enables or disables the module. +- `create_log_file`: creates `output/regex_generator.log` with detailed module + progress messages. This file rotates on the same global + `parameters.rotation` / `parameters.rotation_period` schedule used by the + current Slips run. - `generation_interval_seconds`: delay between completed generation cycles. + Set `0` to start the next cycle immediately after the previous one finishes. - `allowed_backends`: preferred LLM backend aliases for this module. - `llm_temperature`: generation temperature. Kept high to encourage variation. -- `llm_max_tokens`: max tokens for the LLM reply. -- `llm_response_timeout_seconds`: max time to wait for the matching - `llm_response`. -- `recent_history_size`: number of recent same-type regexes included in the - prompt as "do not repeat" history. +- `llm_max_tokens`: max tokens for the LLM reply. The module asks for one regex + line only, so this should stay small. +- `llm_response_timeout_seconds`: soft warning threshold while waiting for the + matching `llm_response`. The module keeps waiting after this. Set `0` to + disable the warning. +- `recent_history_size`: compatibility knob kept at `0`. Prompt history is not + sent to the LLM; repetition is checked locally. - `max_regex_length`: hard reject longer regexes. +- `regex_validation_timeout_seconds`: hard wall-clock timeout for local regex + validation and benign-corpus matching. This prevents one pathological regex + from freezing the module. Set `0` to disable it. - `type_weights`: weighted random choice among the five regex types. - `store_dir`: directory containing `benign_corpus.sqlite` and - `generated_regexes.sqlite`. + `generated_regexes.sqlite`. Absolute paths are used as-is. Relative paths are + resolved inside the current Slips run output directory. The default + `output/regex_generator` therefore becomes `/regex_generator`. +- `persistent_store_dir`: stable absolute directory for the regex SQLite files. + If set, it takes precedence over `store_dir` and lets the generator reuse + the same DBs across many Slips restarts. +- `store_rejected_regexes`: stores rejected regexes in SQLite for audit/debug + purposes. Default `false` so discarded candidates do not fill the disk. +- `max_stored_rejected_regexes`: retention cap for rejected rows when + `store_rejected_regexes` is enabled. Set `0` for unlimited retention. - `seed_benign_samples`: seed the benign DB once with a small built-in sample. ## Runtime flow @@ -66,16 +90,45 @@ Each cycle does this: 2. Choose one backend alias from `allowed_backends`, or fall back to the LLM default backend. 3. Choose the next regex type using weighted random selection. -4. Build a fixed prompt for that type, including recent regex history. +4. Build a minimal fixed prompt for that type. 5. Publish one request on `llm_request`. 6. Wait for the matching `llm_response` using `request_id`. -7. Extract the regex from the returned JSON. + If the local LLM is slow, the module keeps waiting and only logs a warning + after `llm_response_timeout_seconds`. +7. Extract one regex line from the LLM reply. 8. Apply static safety validation. -9. Stream the benign corpus for that type and stop on the first match. -10. Store the result as accepted or rejected. +9. Check local duplicate state with a bloom filter and exact DB lookup. +10. Stream the benign corpus for that type and stop on the first match. +11. Store accepted regexes in SQLite. Rejected regexes are only persisted if + `store_rejected_regexes` is enabled. V1 keeps only one LLM request in flight at a time. +If `create_log_file` is enabled, the module writes detailed cycle logs to: + +```text +output/regex_generator.log +``` + +That file includes: + +- selected regex type +- selected backend +- published `llm_request` `request_id` +- slow-wait warnings while the LLM is still working +- accepted regexes +- rejected regexes and rejection reasons + +Accepted regexes are always stored in: + +```text +/regex_generator/generated_regexes.sqlite +``` + +Rejected regexes are tracked in memory during the current run to prevent cheap +repeats, but they are not stored on disk unless `store_rejected_regexes` is +enabled. + ## LLM contract Request payload: @@ -87,23 +140,18 @@ Request payload: "backend": "local_qwen", "messages": [...], "temperature": 1.2, - "max_tokens": 220, + "max_tokens": 80, "metadata": { "regex_type": "dns_domain", - "prompt_version": "regex-generator-v1", + "prompt_version": "regex-generator-v2", "generation_nonce": "..." } } ``` -The prompt requires the model to return strict raw JSON: - -```json -{ - "regex": "...", - "rationale": "short text" -} -``` +The prompt requires the model to return exactly one regex line. No JSON, +explanation, or code fences. The parser still accepts JSON-shaped replies as a +fallback for compatibility, but the active prompt is raw-regex only. ## Acceptance pipeline @@ -118,18 +166,27 @@ Static validation rejects: - nested wildcard structures that risk catastrophic backtracking - invalid Python/Zeek-compatible syntax -After static validation, the module scans the benign corpus for the selected -type and rejects the regex on the first benign match. +After static validation, the module first checks for exact duplicate regexes +locally with a bloom filter and exact SQLite lookup, then scans the benign +corpus for the selected type and rejects the regex on the first benign match. ## Benign corpus and bloom filters The module creates a dedicated benign corpus DB once and can seed it with a small built-in sample for all supported types. -It also builds one in-memory bloom filter per type, but the bloom filters do -not replace the benign corpus scan. Bloom filters can answer exact-string -membership questions, while acceptance requires checking whether a regex matches -any benign string. +On each run, it also imports domain entries from the configured Slips local +whitelist file into the benign corpus for the matching domain-like regex +types: + +- `dns_domain` +- `tls_sni` +- `certificate_cn` + +It builds one in-memory bloom filter per benign type and one additional bloom +filter for generated regex hashes. These filters speed up exact membership +checks, but they do not replace the benign corpus scan. Acceptance still +requires checking whether a regex matches any benign string. ## Stored regexes @@ -143,3 +200,56 @@ self.db.get_generated_regexes_count(regex_type="dns_domain") ``` These helpers read accepted regexes by default. + +## Offline coverage report + +To estimate how much the accepted regexes cover several reference +populations, run the offline report script by hand against a completed Slips +run output directory: + +```bash +./venv/bin/python scripts/regex_coverage_report.py \ + --run-output-dir output/eno1_2026-03-18_10:00:30 \ + --redis-port 6379 +``` + +By default, large populations are sampled so the script finishes in practical +time. It prints terminal progress while it runs, for example: + +```text +[37/752] type=dns_domain comparisons=742257/8547616 regex=... +``` + +If you want the exhaustive run for research, use: + +```bash +./venv/bin/python scripts/regex_coverage_report.py \ + --run-output-dir /path/to/regex_store \ + --redis-port 23456 \ + --ti-cache-port 6379 \ + --ti-cache-db 1 \ + --full-scan +``` + +Useful knobs: + +- `--full-scan`: disable sampling and scan the full populations. +- `--max-population-size`: sample cap for each population/type in estimate mode. +- `--match-timeout-seconds`: per-regex/per-population timeout guard. + +This generates: + +- `regex_generator_coverage_report.html` +- `regex_generator_coverage_report.json` + +inside the selected run output directory. + +The report estimates coverage against: + +- the local benign corpus DB +- TI-derived malicious reference strings from Redis and TI files +- observed traffic strings from the same run, taken from Zeek logs or + `flows.sqlite` + +The report is offline only. It is not part of the continuous RegexGenerator +loop. From 5656c69b4968d4c192ccf3acb284d06abb99a470 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:10:02 +0000 Subject: [PATCH 0035/1100] feat: update regex generator to v2 with enhanced logging, timeout handling, and improved regex validation --- modules/regex_generator/regex_generator.py | 498 ++++++++++++++++----- 1 file changed, 391 insertions(+), 107 deletions(-) diff --git a/modules/regex_generator/regex_generator.py b/modules/regex_generator/regex_generator.py index 75d812c211..56547be7d7 100644 --- a/modules/regex_generator/regex_generator.py +++ b/modules/regex_generator/regex_generator.py @@ -1,8 +1,10 @@ # SPDX-FileCopyrightText: 2021 Sebastian Garcia # SPDX-License-Identifier: GPL-2.0-only import json +import os import random import re +import signal import time import uuid from hashlib import sha256 @@ -16,81 +18,94 @@ ) -PROMPT_VERSION = "regex-generator-v1" +PROMPT_VERSION = "regex-generator-v2" SYSTEM_PROMPT = """ -You generate one Zeek-compatible detection regex for a single field type. - -Rules: -- Output raw JSON only. -- JSON shape: {"regex":"...","rationale":"..."} -- Return exactly one regex and one short rationale. -- Do not wrap the regex in slashes. -- Do not use code fences. -- Use a conservative regex subset portable to Zeek and Python. -- Do not use lookbehind, named groups, backreferences, or inline flags. -- Avoid catastrophic backtracking and nested wildcards. -- The regex must be specific enough to avoid broad benign matching. -- The regex should target suspicious lexical patterns, not exact known IOCs. -- The regex must differ materially from the recent history list. +Return exactly one regex line. +No JSON. +No explanation. +No code fences. +Do not wrap the regex in slashes. +Use a conservative regex subset portable to Zeek and Python. +Do not use lookbehind, named groups, backreferences, or inline flags. +Avoid catastrophic backtracking and nested wildcards. +Keep it specific enough to avoid broad benign matching. +Keep it under 120 characters. +Model uncommon lexical structure, not explicit threat vocabulary. +Do not use literal threat words, brand names, or exact known IOCs. """.strip() TYPE_PROMPTS = { "dns_domain": """ -Generate a regex for a suspicious DNS domain name. -Focus on lexical patterns often seen in malicious domains: -- long random-looking labels -- encoded-looking subdomains -- suspicious mixtures of letters and digits -- staged subdomains used for tunneling or C2 -Avoid matching common enterprise, CDN, and consumer domains. -The input string is only a domain name, not a URL. -Prefer anchoring with ^ and $ when appropriate. +Generate one DNS domain regex for uncommon suspicious-looking lexical structure. +Target rare structure such as random-looking labels, encoded-looking subdomains, +digit-heavy tokens, awkward token boundaries, or unusual subdomain depth. +The input is only a domain name, not a URL. +Prefer anchors when useful. +Do not use words such as malware, trojan, virus, exploit, c2, bot, or ransom. """.strip(), "uri": """ -Generate a regex for a suspicious HTTP URI path or full request URI. -Focus on suspicious lexical patterns such as: -- staged payload download paths -- fake update or panel paths -- encoded or obfuscated path segments -- suspicious script/file combinations -Avoid matching ordinary website paths like /, /login, /favicon.ico, health checks, -and typical API routes unless the suspicious lexical combination is strong. +Generate one HTTP URI regex for uncommon suspicious-looking lexical structure. +Target rare path structure such as encoded segments, awkward separators, +unusual extension combinations, or long mixed-token segments. +Avoid ordinary website paths unless the lexical structure is clearly unusual. +Do not use words such as malware, trojan, virus, exploit, c2, bot, or ransom. """.strip(), "filename": """ -Generate a regex for a suspicious filename. -Focus on lexical patterns such as: -- double extensions -- lure words with executable/script extensions -- suspicious archive or installer names -- encoded/random-looking names with risky extensions -Avoid matching ordinary office documents, images, backups, and standard installers. -Prefer anchoring with ^ and $ when appropriate. +Generate one filename regex for uncommon suspicious-looking lexical structure. +Target rare structure such as double extensions, deceptive token boundaries, +random-looking names, or unusual risky extension combinations. +Prefer anchors when useful. +Do not use words such as malware, trojan, virus, exploit, c2, bot, or ransom. """.strip(), "tls_sni": """ -Generate a regex for a suspicious TLS SNI hostname. -Focus on lexical patterns such as: -- disposable subdomain structures -- random-looking host labels -- deceptive update or login hostnames -- beacon-like hostnames with unusual token composition -Avoid matching major SaaS, CDN, cloud, browser, and operating-system update hosts. -The input string is only the SNI hostname. -Prefer anchoring with ^ and $ when appropriate. +Generate one TLS SNI hostname regex for uncommon suspicious-looking lexical structure. +Target rare structure such as disposable subdomains, random-looking host labels, +awkward token composition, or deceptive naming without using explicit threat words. +The input is only the SNI hostname. +Prefer anchors when useful. +Do not use words such as malware, trojan, virus, exploit, c2, bot, or ransom. """.strip(), "certificate_cn": """ -Generate a regex for a suspicious X.509 certificate Common Name. -Focus on lexical patterns such as: -- deceptive hostnames -- awkward token combinations suggesting malware infrastructure -- random or encoded-looking names -- names imitating software update or login services -Avoid matching common public web certificates and ordinary enterprise names. -The input string is only the CN text. -Prefer anchoring with ^ and $ when appropriate. +Generate one X.509 certificate Common Name regex for uncommon suspicious-looking lexical structure. +Target rare structure such as deceptive hostnames, awkward token combinations, +random or encoded-looking names, or unusual service-like naming patterns. +The input is only the CN text. +Prefer anchors when useful. +Do not use words such as malware, trojan, virus, exploit, c2, bot, or ransom. """.strip(), } +class _NullTimeout: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, exc_tb): + return False + + +class _SignalTimeout: + def __init__(self, timeout_seconds: float): + self.timeout_seconds = timeout_seconds + self._previous_handler = None + + def __enter__(self): + self._previous_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, self._handle_timeout) + signal.setitimer(signal.ITIMER_REAL, self.timeout_seconds) + return None + + def __exit__(self, exc_type, exc, exc_tb): + signal.setitimer(signal.ITIMER_REAL, 0) + if self._previous_handler is not None: + signal.signal(signal.SIGALRM, self._previous_handler) + return False + + @staticmethod + def _handle_timeout(signum, frame): + raise TimeoutError("regex validation timed out") + + class RegexGenerator(IModule): name = "RegexGenerator" description = "Continuously generates and validates pseudo-random regexes" @@ -103,13 +118,19 @@ def init(self): } self.storage = None self.enabled = False + self.create_log_file = False + self.log_file_path = os.path.join(self.output_dir, "regex_generator.log") + self.enable_log_rotation = True + self.log_rotation_period = 86400 + self.last_log_rotation_time = time.time() self.generation_interval_seconds = 5.0 self.allowed_backends = [] self.llm_temperature = 1.2 - self.llm_max_tokens = 220 + self.llm_max_tokens = 80 self.llm_response_timeout_seconds = 90 - self.recent_history_size = 20 + self.recent_history_size = 0 self.max_regex_length = 180 + self.regex_validation_timeout_seconds = 2.0 self.type_weights = {regex_type: 1.0 for regex_type in REGEX_TYPES} self.pending_request = None self.next_generation_at = 0.0 @@ -123,6 +144,11 @@ def read_configuration(self): else ConfigParser() ) self.enabled = conf.regex_generator_enabled() + self.create_log_file = conf.regex_generator_create_log_file() + self.enable_log_rotation = conf.rotation() + self.log_rotation_period = self._parse_rotation_period_seconds( + conf.rotation_period() + ) self.generation_interval_seconds = ( conf.regex_generator_generation_interval_seconds() ) @@ -134,6 +160,9 @@ def read_configuration(self): ) self.recent_history_size = conf.regex_generator_recent_history_size() self.max_regex_length = conf.regex_generator_max_regex_length() + self.regex_validation_timeout_seconds = ( + conf.regex_generator_regex_validation_timeout_seconds() + ) self.type_weights = conf.regex_generator_type_weights() def pre_main(self): @@ -143,6 +172,7 @@ def pre_main(self): self.print("RegexGenerator module disabled in config.", 2, 0) return True + self._init_log_file() self.storage = RegexGeneratorStorage( self.logger, self.conf, @@ -150,6 +180,16 @@ def pre_main(self): self.ppid, ) self.next_generation_at = time.time() + self._log_detail("RegexGenerator module ready.") + self._log_detail( + f"Using storage at {self.storage.store_dir}. " + f"Benign corpus DB: {self.storage.benign_db.db_path}. " + f"Generated regex DB: {self.storage.generated_db.db_path}." + ) + self._log_detail( + "Rejected regex persistence is " + f"{'enabled' if self.storage.store_rejected_regexes else 'disabled'}." + ) self.print("RegexGenerator module ready.", 2, 0) def shutdown_gracefully(self): @@ -170,6 +210,9 @@ def main(self): available_backends = self.db.get_available_llm_backends() backend = self._select_backend(available_backends) if not backend: + self._log_detail( + "No runtime-ready LLM backend available yet. Waiting for discovery." + ) self.print( "RegexGenerator is waiting for a runtime-ready LLM backend.", 2, @@ -180,8 +223,84 @@ def main(self): return regex_type = self._choose_regex_type() + self._log_detail( + f"Starting generation cycle. regex_type={regex_type} backend={backend}" + ) self._send_generation_request(regex_type, backend) + def _init_log_file(self): + if not self.create_log_file: + return + + os.makedirs(self.output_dir, exist_ok=True) + if not os.path.exists(self.log_file_path): + with open(self.log_file_path, "w", encoding="utf-8") as log_file: + log_file.write("") + self.last_log_rotation_time = time.time() + + def _log_detail(self, text: str): + if not self.create_log_file: + return + + self._rotate_log_file_if_needed() + human_readable_datetime = utils.convert_ts_format( + time.time(), utils.alerts_format + ) + with open(self.log_file_path, "a", encoding="utf-8") as log_file: + log_file.write(f"{human_readable_datetime} - {text}\n") + + def _rotate_log_file_if_needed(self): + if not self.enable_log_rotation or self.log_rotation_period <= 0: + return + + now = time.time() + if now - self.last_log_rotation_time < self.log_rotation_period: + return + + if os.path.exists(self.log_file_path) and os.path.getsize( + self.log_file_path + ) > 0: + timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(now)) + rotated_path = f"{self.log_file_path}.{timestamp}" + os.replace(self.log_file_path, rotated_path) + + with open(self.log_file_path, "w", encoding="utf-8") as log_file: + log_file.write("") + self.last_log_rotation_time = now + + @staticmethod + def _parse_rotation_period_seconds(rotation_period) -> int: + if isinstance(rotation_period, (int, float)): + return max(1, int(rotation_period)) + + text = str(rotation_period or "").strip().lower().replace(" ", "") + match = re.fullmatch( + r"(?P\d+)(?Psec|secs|second|seconds|min|mins|minute|minutes|hr|hrs|hour|hours|day|days)", + text, + ) + if not match: + return 86400 + + value = int(match.group("value")) + unit = match.group("unit") + multipliers = { + "sec": 1, + "secs": 1, + "second": 1, + "seconds": 1, + "min": 60, + "mins": 60, + "minute": 60, + "minutes": 60, + "hr": 3600, + "hrs": 3600, + "hour": 3600, + "hours": 3600, + "day": 86400, + "days": 86400, + } + return max(1, value * multipliers[unit]) + def _select_backend(self, available_backends: dict) -> str: available = available_backends.get("backends", {}) if not available: @@ -225,12 +344,17 @@ def _send_generation_request(self, regex_type: str, backend: str): self.db.channels.LLM_REQUEST, json.dumps(request), ) + self._log_detail( + f"Published llm_request request_id={request_id} " + f"regex_type={regex_type} backend={backend}" + ) self.pending_request = { "request_id": request_id, "regex_type": regex_type, "backend": backend, "sent_at": time.time(), "generation_nonce": generation_nonce, + "last_warning_at": 0.0, } def _build_prompt_messages( @@ -238,30 +362,15 @@ def _build_prompt_messages( regex_type: str, generation_nonce: str, ) -> list: - history = self.storage.get_recent_history( - regex_type, self.recent_history_size - ) - benign_examples = self.storage.get_benign_examples(regex_type, limit=5) - - history_lines = [] - for item in history: - history_lines.append( - f'- status={item["status"]} regex={item["regex"]}' - ) - history_text = "\n".join(history_lines) or "- none" - benign_text = "\n".join(f"- {value}" for value in benign_examples) - benign_text = benign_text or "- none" - user_prompt = ( - f"Regex type: {regex_type}\n" + f"Type: {regex_type}\n" f"Prompt version: {PROMPT_VERSION}\n" - f"Generation nonce: {generation_nonce}\n\n" - f"{TYPE_PROMPTS[regex_type]}\n\n" - "Recent regex history that must not be repeated or trivially rewritten:\n" - f"{history_text}\n\n" - "Common benign examples that must stay unmatched if reasonably possible:\n" - f"{benign_text}\n\n" - "Return strict raw JSON only." + f"Nonce: {generation_nonce}\n" + "Goal: generate a regex for uncommon suspicious-looking lexical structure.\n" + "Prefer structural contrast over explicit malicious words.\n" + "Do not repeat previous generations.\n" + f"{TYPE_PROMPTS[regex_type]}\n" + "Return one regex only." ) return [ {"role": "system", "content": SYSTEM_PROMPT}, @@ -269,15 +378,7 @@ def _build_prompt_messages( ] def _handle_pending_response(self, now: float): - if now - self.pending_request["sent_at"] > self.llm_response_timeout_seconds: - self.print( - "RegexGenerator request timed out waiting for llm_response.", - 0, - 1, - ) - self.pending_request = None - self.next_generation_at = now + self.generation_interval_seconds - return + self._warn_if_llm_is_slow(now) if not (msg := self.get_msg(self.db.channels.LLM_RESPONSE)): time.sleep(0.1) @@ -291,12 +392,43 @@ def _handle_pending_response(self, now: float): if response.get("request_id") != self.pending_request["request_id"]: return + self._log_detail( + f"Received matching llm_response request_id={response.get('request_id')}" + ) self._finalize_request(response) self.pending_request = None self.next_generation_at = time.time() + self.generation_interval_seconds + def _warn_if_llm_is_slow(self, now: float): + if self.llm_response_timeout_seconds <= 0: + return + + elapsed = now - self.pending_request["sent_at"] + if elapsed <= self.llm_response_timeout_seconds: + return + + last_warning_at = self.pending_request.get("last_warning_at", 0.0) + warning_interval = max(30.0, float(self.llm_response_timeout_seconds)) + if last_warning_at and now - last_warning_at < warning_interval: + return + + self.print( + f"RegexGenerator is still waiting for llm_response after {elapsed:.1f}s.", + 2, + 0, + ) + self._log_detail( + f"Still waiting for llm_response request_id=" + f"{self.pending_request['request_id']} elapsed={elapsed:.1f}s" + ) + self.pending_request["last_warning_at"] = now + def _finalize_request(self, response: dict): if not response.get("success"): + self._log_detail( + f"LLM response failed request_id={response.get('request_id')} " + f"error={response.get('error', 'unknown')}" + ) self.print( f"RegexGenerator LLM error: {response.get('error', 'unknown')}", 0, @@ -307,6 +439,11 @@ def _finalize_request(self, response: dict): llm_text = response.get("text", "") regex, rejection_reason = self._extract_regex_from_llm_text(llm_text) if rejection_reason: + self._log_detail( + f"Rejected malformed LLM response request_id=" + f"{self.pending_request['request_id']} reason={rejection_reason} " + f"raw_preview={self._short_preview(llm_text)!r}" + ) self.print( f"RegexGenerator rejected malformed LLM response: {rejection_reason}", 0, @@ -329,10 +466,13 @@ def _finalize_request(self, response: dict): self._validate_and_store_regex(record) def _extract_regex_from_llm_text(self, llm_text: str) -> tuple[str, str | None]: - try: - payload = json.loads(llm_text) - except (TypeError, json.JSONDecodeError): - return "", "invalid_json" + raw_regex = self._extract_raw_regex_candidate(llm_text) + if raw_regex: + return raw_regex, None + + payload = self._extract_json_payload(llm_text) + if payload is None: + return "", "invalid_response" if not isinstance(payload, dict): return "", "response_not_object" @@ -343,29 +483,153 @@ def _extract_regex_from_llm_text(self, llm_text: str) -> tuple[str, str | None]: return regex.strip(), None + @staticmethod + def _extract_raw_regex_candidate(llm_text: str) -> str: + if not isinstance(llm_text, str): + return "" + + text = RegexGenerator._strip_code_fences(llm_text).strip() + if not text: + return "" + + for line in text.splitlines(): + candidate = line.strip().strip("`").strip() + if not candidate: + continue + if candidate.lower().startswith("regex:"): + candidate = candidate.split(":", 1)[1].strip() + candidate = candidate.strip().strip('"').strip("'") + if candidate.startswith("/") and candidate.endswith("/") and len(candidate) > 1: + candidate = candidate[1:-1].strip() + if not candidate or " " in candidate or candidate.startswith("{"): + continue + if not re.search(r"[\^\$\[\]\(\)\{\}\\\.\|\*\+\?]", candidate): + continue + return candidate + + return "" + + @staticmethod + def _strip_code_fences(text: str) -> str: + stripped = text.strip() + if not stripped.startswith("```"): + return stripped + + lines = stripped.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + return "\n".join(lines).strip() + + @staticmethod + def _extract_json_payload(llm_text: str) -> dict | None: + if not isinstance(llm_text, str): + return None + + candidates = [llm_text.strip()] + fenced_match = re.search( + r"```(?:json)?\s*(\{.*?\})\s*```", + llm_text, + flags=re.DOTALL, + ) + if fenced_match: + candidates.append(fenced_match.group(1).strip()) + + object_text = RegexGenerator._extract_first_json_object(llm_text) + if object_text: + candidates.append(object_text) + + for candidate in candidates: + if not candidate: + continue + try: + return json.loads(candidate) + except (TypeError, json.JSONDecodeError): + continue + + return None + + @staticmethod + def _extract_first_json_object(text: str) -> str | None: + start = text.find("{") + while start != -1: + depth = 0 + in_string = False + escaped = False + for idx in range(start, len(text)): + char = text[idx] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[start : idx + 1].strip() + + start = text.find("{", start + 1) + + return None + + @staticmethod + def _short_preview(text: str, limit: int = 200) -> str: + text = " ".join(str(text).split()) + if len(text) <= limit: + return text + return f"{text[:limit]}..." + @staticmethod def _hash_regex(regex: str) -> str: return sha256(regex.encode("utf-8")).hexdigest() def _validate_and_store_regex(self, record: dict): - validation_error = self._validate_regex(record["regex"]) + try: + with self._regex_validation_timeout(): + validation_error = self._validate_regex(record["regex"]) + except TimeoutError: + self._store_rejected_regex(record, "regex_validation_timeout") + return + if validation_error: self._store_rejected_regex(record, validation_error) return - if self.storage.get_existing_generated_regex(record["regex_hash"]): - self.print( - f"RegexGenerator rejected duplicate regex: {record['regex']}", - 2, - 0, - ) + if self.storage.might_have_generated_regex(record["regex_hash"]): + if self.storage.get_existing_generated_regex( + record["regex_hash"] + ) or self.storage.was_rejected_in_current_run(record["regex_hash"]): + self._log_detail( + f"Rejected duplicate regex request_id={record['request_id']} " + f"regex_type={record['regex_type']} regex={record['regex']}" + ) + self.print( + f"RegexGenerator rejected duplicate regex: {record['regex']}", + 2, + 0, + ) + return + + try: + with self._regex_validation_timeout(): + compiled_regex = re.compile(record["regex"]) + matched_benign = self._find_matching_benign_value( + record["regex_type"], + compiled_regex, + ) + except TimeoutError: + self._store_rejected_regex(record, "regex_validation_timeout") return - compiled_regex = re.compile(record["regex"]) - matched_benign = self._find_matching_benign_value( - record["regex_type"], - compiled_regex, - ) if matched_benign: self._store_rejected_regex( record, @@ -378,6 +642,10 @@ def _validate_and_store_regex(self, record: dict): record["rejection_reason"] = None record["matched_benign_value"] = None self.storage.store_generated_regex(record) + self._log_detail( + f"Accepted regex request_id={record['request_id']} " + f"regex_type={record['regex_type']} regex={record['regex']}" + ) def _store_rejected_regex( self, @@ -389,6 +657,22 @@ def _store_rejected_regex( record["rejection_reason"] = rejection_reason record["matched_benign_value"] = matched_benign_value self.storage.store_generated_regex(record) + extra = ( + f" matched_benign_value={matched_benign_value}" + if matched_benign_value + else "" + ) + self._log_detail( + f"Rejected regex request_id={record['request_id']} " + f"regex_type={record['regex_type']} reason={rejection_reason}" + f"{extra} regex={record['regex']}" + ) + + def _regex_validation_timeout(self): + timeout = float(self.regex_validation_timeout_seconds) + if timeout <= 0: + return _NullTimeout() + return _SignalTimeout(timeout) def _validate_regex(self, regex: str) -> str | None: try: From 6f01c380cbe54695d17aad65d8f8bc8ead19b885 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:10:27 +0000 Subject: [PATCH 0036/1100] feat: enhance regex generator configuration with new options for rejected regexes and logging --- tests/module_factory.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/module_factory.py b/tests/module_factory.py index a113338fb1..8a14fb0e80 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -76,6 +76,11 @@ def create_db_manager_obj( conf.regex_generator_store_dir = Mock( return_value=os.path.join(output_dir, "regex_generator") ) + conf.regex_generator_persistent_store_dir = Mock(return_value="") + conf.regex_generator_store_rejected_regexes = Mock(return_value=False) + conf.regex_generator_max_stored_rejected_regexes = Mock( + return_value=10000 + ) conf.regex_generator_seed_benign_samples = Mock(return_value=True) with ( @@ -179,13 +184,17 @@ def create_regex_generator_obj(self, mock_db, store_dir="dummy_output_dir/regex_ conf = Mock() conf.regex_generator_enabled = Mock(return_value=True) + conf.regex_generator_create_log_file = Mock(return_value=False) conf.regex_generator_generation_interval_seconds = Mock(return_value=5) conf.regex_generator_allowed_backends = Mock(return_value=["local_qwen"]) conf.regex_generator_llm_temperature = Mock(return_value=1.2) - conf.regex_generator_llm_max_tokens = Mock(return_value=220) + conf.regex_generator_llm_max_tokens = Mock(return_value=80) conf.regex_generator_llm_response_timeout_seconds = Mock(return_value=90) - conf.regex_generator_recent_history_size = Mock(return_value=20) + conf.regex_generator_recent_history_size = Mock(return_value=0) conf.regex_generator_max_regex_length = Mock(return_value=180) + conf.regex_generator_regex_validation_timeout_seconds = Mock( + return_value=2 + ) conf.regex_generator_type_weights = Mock( return_value={ "dns_domain": 1, @@ -196,7 +205,14 @@ def create_regex_generator_obj(self, mock_db, store_dir="dummy_output_dir/regex_ } ) conf.regex_generator_store_dir = Mock(return_value=store_dir) + conf.regex_generator_persistent_store_dir = Mock(return_value="") + conf.regex_generator_store_rejected_regexes = Mock(return_value=False) + conf.regex_generator_max_stored_rejected_regexes = Mock( + return_value=10000 + ) conf.regex_generator_seed_benign_samples = Mock(return_value=True) + conf.rotation = Mock(return_value=True) + conf.rotation_period = Mock(return_value="1day") regex_generator = RegexGenerator( logger=self.logger, From 23f88976c267b60e4cd86ce5a1db3eabdd370014 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:11:59 +0000 Subject: [PATCH 0037/1100] feat: enhance regex generator configuration with new options for logging and validation --- slips_files/common/parsers/config_parser.py | 56 ++++++++++++++++++--- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index 5e065aa8f4..730dd47d4a 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -740,7 +740,15 @@ def regex_generator_generation_interval_seconds(self) -> float: value = float(value) except (TypeError, ValueError): value = 5 - return max(0.1, value) + return max(0.0, value) + + def regex_generator_create_log_file(self) -> bool: + value = self.read_configuration( + "regex_generator", "create_log_file", False + ) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") def regex_generator_allowed_backends(self) -> list: value = self.read_configuration( @@ -766,12 +774,12 @@ def regex_generator_llm_temperature(self) -> float: def regex_generator_llm_max_tokens(self) -> int: value = self.read_configuration( - "regex_generator", "llm_max_tokens", 220 + "regex_generator", "llm_max_tokens", 80 ) try: value = int(value) except (TypeError, ValueError): - value = 220 + value = 80 return max(1, value) def regex_generator_llm_response_timeout_seconds(self) -> int: @@ -782,16 +790,16 @@ def regex_generator_llm_response_timeout_seconds(self) -> int: value = int(value) except (TypeError, ValueError): value = 90 - return max(1, value) + return max(0, value) def regex_generator_recent_history_size(self) -> int: value = self.read_configuration( - "regex_generator", "recent_history_size", 20 + "regex_generator", "recent_history_size", 0 ) try: value = int(value) except (TypeError, ValueError): - value = 20 + value = 0 return max(0, value) def regex_generator_max_regex_length(self) -> int: @@ -804,6 +812,16 @@ def regex_generator_max_regex_length(self) -> int: value = 180 return max(1, value) + def regex_generator_regex_validation_timeout_seconds(self) -> float: + value = self.read_configuration( + "regex_generator", "regex_validation_timeout_seconds", 2 + ) + try: + value = float(value) + except (TypeError, ValueError): + value = 2.0 + return max(0.0, value) + def regex_generator_type_weights(self) -> dict: default_weights = { "dns_domain": 1, @@ -839,6 +857,32 @@ def regex_generator_store_dir(self) -> str: return "output/regex_generator" return value.strip() + def regex_generator_persistent_store_dir(self) -> str: + value = self.read_configuration( + "regex_generator", "persistent_store_dir", "" + ) + if not isinstance(value, str) or not value.strip(): + return "" + return value.strip() + + def regex_generator_store_rejected_regexes(self) -> bool: + value = self.read_configuration( + "regex_generator", "store_rejected_regexes", False + ) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") + + def regex_generator_max_stored_rejected_regexes(self) -> int: + value = self.read_configuration( + "regex_generator", "max_stored_rejected_regexes", 10000 + ) + try: + value = int(value) + except (TypeError, ValueError): + return 10000 + return max(0, value) + def regex_generator_seed_benign_samples(self) -> bool: value = self.read_configuration( "regex_generator", "seed_benign_samples", True From 831646cd7c9e7bde613df86cc028c07349abbbde Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:13:38 +0000 Subject: [PATCH 0038/1100] feat: add unit tests for regex generator configuration and functionality --- .../database/sqlite_db/regex_generator_db.py | 698 +++++++++++++++ .../regex_generator/test_regex_generator.py | 808 ++++++++++++++++++ 2 files changed, 1506 insertions(+) create mode 100644 slips_files/core/database/sqlite_db/regex_generator_db.py create mode 100644 tests/unit/modules/regex_generator/test_regex_generator.py diff --git a/slips_files/core/database/sqlite_db/regex_generator_db.py b/slips_files/core/database/sqlite_db/regex_generator_db.py new file mode 100644 index 0000000000..2819b41990 --- /dev/null +++ b/slips_files/core/database/sqlite_db/regex_generator_db.py @@ -0,0 +1,698 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import hashlib +import os +from pathlib import Path +from time import time +from typing import Dict, Iterable, List + +from pybloom_live import ScalableBloomFilter + +from slips_files.common.abstracts.isqlite import ISQLite +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.common.printer import Printer +from slips_files.common.slips_utils import utils +from slips_files.core.output import Output + +REGEX_TYPES = ( + "dns_domain", + "uri", + "filename", + "tls_sni", + "certificate_cn", +) +DEFAULT_REGEX_GENERATOR_STORE_DIR = "output/regex_generator" +DEFAULT_BENIGN_SEED_SAMPLES = { + "dns_domain": [ + "google.com", + "microsoft.com", + "github.com", + "cloudflare.com", + "ubuntu.com", + ], + "uri": [ + "/", + "/index.html", + "/favicon.ico", + "/api/v1/health", + "/login", + ], + "filename": [ + "document.pdf", + "invoice-2024.xlsx", + "photo.jpg", + "notes.txt", + "setup.exe", + ], + "tls_sni": [ + "www.google.com", + "api.github.com", + "login.microsoftonline.com", + "cdn.cloudflare.com", + "packages.ubuntu.com", + ], + "certificate_cn": [ + "www.google.com", + "github.com", + "login.microsoftonline.com", + "letsencrypt.org", + "updates.ubuntu.com", + ], +} +WHITELIST_COMPATIBLE_REGEX_TYPES = ( + "dns_domain", + "tls_sni", + "certificate_cn", +) + + +def _make_sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +class _BaseRegexSQLiteDB(ISQLite): + name = "BaseRegexSQLiteDB" + + def __init__(self, logger: Output, db_path: str, main_pid: int): + self.printer = Printer(logger, self.name) + self.db_path = db_path + self._init_db_file() + super().__init__(self.name.lower(), main_pid, db_path) + self.init_tables() + + def _init_db_file(self): + db_file = Path(self.db_path) + db_file.parent.mkdir(parents=True, exist_ok=True) + if not db_file.exists(): + db_file.touch() + os.chmod(db_file, 0o777) + + +class BenignCorpusSQLiteDB(_BaseRegexSQLiteDB): + name = "BenignCorpusSQLiteDB" + + def init_tables(self): + self.create_table( + "benign_strings", + "id INTEGER PRIMARY KEY, regex_type TEXT NOT NULL, value TEXT NOT NULL, " + "value_hash TEXT NOT NULL UNIQUE, source TEXT NOT NULL, created_at REAL NOT NULL", + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_benign_strings_type_hash " + "ON benign_strings (regex_type, value_hash)" + ) + + def is_empty(self) -> bool: + return self.get_count("benign_strings") == 0 + + def insert_benign_string( + self, + regex_type: str, + value: str, + source: str, + created_at: float | None = None, + ): + created_at = created_at or time() + value_hash = _make_sha256(f"{regex_type}\0{value}") + self.execute( + "INSERT OR IGNORE INTO benign_strings " + "(regex_type, value, value_hash, source, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (regex_type, value, value_hash, source, created_at), + ) + + def seed_strings(self, seed_samples: Dict[str, Iterable[str]], source: str): + for regex_type, values in seed_samples.items(): + for value in values: + self.insert_benign_string(regex_type, value, source) + + def get_examples(self, regex_type: str, limit: int = 5) -> List[str]: + rows = self.select( + "benign_strings", + columns="value", + condition="regex_type = ?", + params=(regex_type,), + order_by="id ASC", + ) + rows = rows or [] + return [row[0] for row in rows[:limit]] + + def iter_values(self, regex_type: str): + cursor = self.execute( + "SELECT value FROM benign_strings WHERE regex_type = ? ORDER BY id ASC", + (regex_type,), + ) + if not cursor: + return + + while True: + row = self.fetchone(cursor) + if row is None: + break + yield row[0] + + +class GeneratedRegexSQLiteDB(_BaseRegexSQLiteDB): + name = "GeneratedRegexSQLiteDB" + + def init_tables(self): + self.create_table( + "generated_regexes", + "id INTEGER PRIMARY KEY, regex_type TEXT NOT NULL, regex TEXT NOT NULL, " + "regex_hash TEXT NOT NULL UNIQUE, status TEXT NOT NULL, " + "rejection_reason TEXT, matched_benign_value TEXT, backend_alias TEXT, " + "provider TEXT, model TEXT, temperature REAL, prompt_version TEXT, " + "request_id TEXT, created_at REAL NOT NULL", + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_generated_regexes_status_type_created " + "ON generated_regexes (status, regex_type, created_at)" + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_generated_regexes_type_created " + "ON generated_regexes (regex_type, created_at)" + ) + + @staticmethod + def _row_to_dict(row) -> dict: + return { + "id": row[0], + "regex_type": row[1], + "regex": row[2], + "regex_hash": row[3], + "status": row[4], + "rejection_reason": row[5], + "matched_benign_value": row[6], + "backend_alias": row[7], + "provider": row[8], + "model": row[9], + "temperature": row[10], + "prompt_version": row[11], + "request_id": row[12], + "created_at": row[13], + } + + def get_by_hash(self, regex_hash: str) -> dict | None: + row = self.select( + "generated_regexes", + condition="regex_hash = ?", + params=(regex_hash,), + limit=1, + ) + if not row: + return None + return self._row_to_dict(row) + + def insert_generated_regex(self, record: dict): + self.execute( + "INSERT OR IGNORE INTO generated_regexes " + "(regex_type, regex, regex_hash, status, rejection_reason, " + "matched_benign_value, backend_alias, provider, model, temperature, " + "prompt_version, request_id, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + record["regex_type"], + record["regex"], + record["regex_hash"], + record["status"], + record.get("rejection_reason"), + record.get("matched_benign_value"), + record.get("backend_alias"), + record.get("provider"), + record.get("model"), + record.get("temperature"), + record.get("prompt_version"), + record.get("request_id"), + record.get("created_at") or time(), + ), + ) + + def get_recent_history(self, regex_type: str, limit: int) -> List[dict]: + rows = self.select( + "generated_regexes", + condition="regex_type = ?", + params=(regex_type,), + order_by="created_at DESC", + ) + rows = rows or [] + return [self._row_to_dict(row) for row in rows[:limit]] + + def get_generated_regexes( + self, + regex_type: str | None = None, + limit: int | None = None, + status: str = "accepted", + ) -> List[dict]: + condition_parts = [] + params = [] + if status: + condition_parts.append("status = ?") + params.append(status) + if regex_type: + condition_parts.append("regex_type = ?") + params.append(regex_type) + + condition = " AND ".join(condition_parts) if condition_parts else None + rows = self.select( + "generated_regexes", + condition=condition, + params=tuple(params), + order_by="created_at DESC", + ) + rows = rows or [] + if limit is not None: + rows = rows[:limit] + return [self._row_to_dict(row) for row in rows] + + def get_generated_regexes_count( + self, + regex_type: str | None = None, + status: str = "accepted", + ) -> int: + condition_parts = [] + params = [] + if status: + condition_parts.append("status = ?") + params.append(status) + if regex_type: + condition_parts.append("regex_type = ?") + params.append(regex_type) + + condition = " AND ".join(condition_parts) if condition_parts else None + row = self.select( + "generated_regexes", + columns="COUNT(*)", + condition=condition, + params=tuple(params), + limit=1, + ) + return row[0] if row else 0 + + def iter_regex_hashes(self, status: str | None = None): + query = "SELECT regex_hash FROM generated_regexes" + params = () + if status: + query += " WHERE status = ?" + params = (status,) + query += " ORDER BY id ASC" + cursor = self.execute( + query, + params, + ) + if not cursor: + return + + while True: + row = self.fetchone(cursor) + if row is None: + break + yield row[0] + + def prune_rejected_regexes(self, max_records: int): + if max_records <= 0: + return + + count = self.get_generated_regexes_count(status="rejected") + excess = count - max_records + if excess <= 0: + return + + self.execute( + "DELETE FROM generated_regexes WHERE id IN (" + "SELECT id FROM generated_regexes " + "WHERE status = 'rejected' " + "ORDER BY created_at ASC, id ASC LIMIT ?" + ")", + (excess,), + ) + + +class RegexGeneratorStorage: + def __init__( + self, + logger: Output, + conf, + output_dir: str, + main_pid: int, + ): + self.logger = logger + self.conf = conf + self.output_dir = output_dir + self.main_pid = main_pid + self.store_dir = self._resolve_store_dir() + self.store_rejected_regexes = self._read_store_rejected_regexes() + self.max_stored_rejected_regexes = ( + self._read_max_stored_rejected_regexes() + ) + self.seed_benign_samples = self._read_seed_benign_samples() + self.enable_local_whitelist = self._read_enable_local_whitelist() + self.local_whitelist_path = self._read_local_whitelist_path() + self.benign_db = BenignCorpusSQLiteDB( + self.logger, + str(Path(self.store_dir) / "benign_corpus.sqlite"), + self.main_pid, + ) + self.generated_db = GeneratedRegexSQLiteDB( + self.logger, + str(Path(self.store_dir) / "generated_regexes.sqlite"), + self.main_pid, + ) + if self.seed_benign_samples and self.benign_db.is_empty(): + self.seed_default_benign_samples() + self._import_local_whitelist_into_benign_corpus() + self.bloom_filters = self._build_bloom_filters() + self.generated_regex_filter = self._build_generated_regex_filter() + self.rejected_regex_filter = self._build_rejected_regex_filter() + + def _resolve_store_dir(self) -> str: + raw_store_dir = self._read_store_dir() + store_dir = self._normalize_store_dir(raw_store_dir) + store_dir.mkdir(parents=True, exist_ok=True) + return str(store_dir) + + def _normalize_store_dir(self, raw_store_dir: str) -> Path: + store_dir = Path(raw_store_dir).expanduser() + if store_dir.is_absolute(): + return store_dir + + relative_parts = list(store_dir.parts) + while relative_parts and relative_parts[0] == ".": + relative_parts = relative_parts[1:] + if relative_parts and relative_parts[0] == "output": + relative_parts = relative_parts[1:] + if not relative_parts: + relative_parts = ["regex_generator"] + + return Path(self.output_dir).expanduser().joinpath(*relative_parts) + + def _read_store_dir(self) -> str: + persistent_value = self._read_string_config( + "regex_generator_persistent_store_dir" + ) + if persistent_value: + return persistent_value + + value = self._read_string_config("regex_generator_store_dir") + if value: + return value + + parser = ConfigParser() + persistent_getter = getattr( + parser, "regex_generator_persistent_store_dir", None + ) + if callable(persistent_getter): + try: + persistent_value = persistent_getter() + except TypeError: + persistent_value = None + if isinstance(persistent_value, str) and persistent_value.strip(): + return persistent_value.strip() + + parser_getter = getattr(parser, "regex_generator_store_dir", None) + if callable(parser_getter): + try: + value = parser_getter() + except TypeError: + value = None + if isinstance(value, str) and value.strip(): + return value.strip() + return DEFAULT_REGEX_GENERATOR_STORE_DIR + + def _read_seed_benign_samples(self) -> bool: + value = self._read_bool_config("regex_generator_seed_benign_samples") + if value is not None: + return value + + parser = ConfigParser() + parser_getter = getattr(parser, "regex_generator_seed_benign_samples", None) + if callable(parser_getter): + try: + value = parser_getter() + except TypeError: + value = None + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes", "on") + return True + + def _read_store_rejected_regexes(self) -> bool: + value = self._read_bool_config("regex_generator_store_rejected_regexes") + if value is not None: + return value + + parser = ConfigParser() + parser_getter = getattr( + parser, "regex_generator_store_rejected_regexes", None + ) + if callable(parser_getter): + try: + value = parser_getter() + except TypeError: + value = None + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes", "on") + return False + + def _read_max_stored_rejected_regexes(self) -> int: + value = self._read_int_config("regex_generator_max_stored_rejected_regexes") + if value is not None: + return max(0, value) + + parser = ConfigParser() + parser_getter = getattr( + parser, "regex_generator_max_stored_rejected_regexes", None + ) + if callable(parser_getter): + try: + value = parser_getter() + except TypeError: + value = None + if isinstance(value, int): + return max(0, value) + if isinstance(value, str): + try: + return max(0, int(value.strip())) + except ValueError: + pass + return 10000 + + def _read_enable_local_whitelist(self) -> bool: + value = self._read_bool_config("enable_local_whitelist") + if value is not None: + return value + + parser = ConfigParser() + parser_getter = getattr(parser, "enable_local_whitelist", None) + if callable(parser_getter): + try: + value = parser_getter() + except TypeError: + value = None + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes", "on") + return True + + def _read_local_whitelist_path(self) -> str: + value = self._read_string_config("local_whitelist_path") + if value: + return value + + parser = ConfigParser() + parser_getter = getattr(parser, "local_whitelist_path", None) + if callable(parser_getter): + try: + value = parser_getter() + except TypeError: + value = None + if isinstance(value, str) and value.strip(): + return value.strip() + return "config/whitelist.conf" + + def _read_string_config(self, method_name: str) -> str | None: + getter = getattr(self.conf, method_name, None) + if not callable(getter): + return None + try: + value = getter() + except TypeError: + return None + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + def _read_bool_config(self, method_name: str) -> bool | None: + getter = getattr(self.conf, method_name, None) + if not callable(getter): + return None + try: + value = getter() + except TypeError: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes", "on") + return None + + def _read_int_config(self, method_name: str) -> int | None: + getter = getattr(self.conf, method_name, None) + if not callable(getter): + return None + try: + value = getter() + except TypeError: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def seed_default_benign_samples(self): + self.benign_db.seed_strings( + DEFAULT_BENIGN_SEED_SAMPLES, + source="seed_v1", + ) + + def _import_local_whitelist_into_benign_corpus(self): + if not self.enable_local_whitelist: + return + + whitelist_path = Path(self.local_whitelist_path).expanduser() + if not whitelist_path.is_absolute(): + whitelist_path = Path(os.getcwd()) / whitelist_path + if not whitelist_path.exists(): + return + + for domain in self._iter_whitelist_domains(whitelist_path): + hostname = utils.extract_hostname(domain) + values = {domain} + if hostname: + values.add(hostname) + + for regex_type in WHITELIST_COMPATIBLE_REGEX_TYPES: + for value in values: + self.benign_db.insert_benign_string( + regex_type, + value, + source=f"local_whitelist:{whitelist_path}", + ) + + @staticmethod + def _iter_whitelist_domains(whitelist_path: Path): + with open(whitelist_path, encoding="utf-8") as whitelist: + for raw_line in whitelist: + if ( + not raw_line + or raw_line.startswith(";") + or raw_line.startswith("#") + or raw_line.startswith('"IoCType"') + ): + continue + + line = raw_line.replace("\n", "").replace(" ", "") + parts = line.split(",") + if len(parts) < 4: + continue + if parts[0].lower() != "domain": + continue + + domain = parts[1].strip().lower() + if not utils.is_valid_domain(domain): + continue + yield domain + + def _build_bloom_filters(self) -> dict: + bloom_filters = {} + for regex_type in REGEX_TYPES: + bloom = ScalableBloomFilter( + mode=ScalableBloomFilter.SMALL_SET_GROWTH, + error_rate=0.001, + ) + for value in self.benign_db.iter_values(regex_type): + bloom.add(value) + bloom_filters[regex_type] = bloom + return bloom_filters + + def _build_generated_regex_filter(self): + bloom = ScalableBloomFilter( + mode=ScalableBloomFilter.SMALL_SET_GROWTH, + error_rate=0.001, + ) + for regex_hash in self.generated_db.iter_regex_hashes(): + bloom.add(regex_hash) + return bloom + + def _build_rejected_regex_filter(self): + return ScalableBloomFilter( + mode=ScalableBloomFilter.SMALL_SET_GROWTH, + error_rate=0.001, + ) + + def get_benign_examples(self, regex_type: str, limit: int = 5) -> List[str]: + return self.benign_db.get_examples(regex_type, limit) + + def iter_benign_strings(self, regex_type: str): + yield from self.benign_db.iter_values(regex_type) + + def get_recent_history(self, regex_type: str, limit: int) -> List[dict]: + return self.generated_db.get_recent_history(regex_type, limit) + + def get_generated_regexes( + self, + regex_type: str | None = None, + limit: int | None = None, + status: str = "accepted", + ) -> List[dict]: + return self.generated_db.get_generated_regexes( + regex_type=regex_type, + limit=limit, + status=status, + ) + + def get_generated_regexes_count( + self, + regex_type: str | None = None, + status: str = "accepted", + ) -> int: + return self.generated_db.get_generated_regexes_count( + regex_type=regex_type, + status=status, + ) + + def get_existing_generated_regex(self, regex_hash: str) -> dict | None: + return self.generated_db.get_by_hash(regex_hash) + + def might_have_generated_regex(self, regex_hash: str) -> bool: + return ( + regex_hash in self.generated_regex_filter + or regex_hash in self.rejected_regex_filter + ) + + def was_rejected_in_current_run(self, regex_hash: str) -> bool: + return regex_hash in self.rejected_regex_filter + + def store_generated_regex(self, record: dict): + regex_hash = record["regex_hash"] + status = record.get("status", "") + + if status == "rejected": + self.rejected_regex_filter.add(regex_hash) + if not self.store_rejected_regexes: + return + + self.generated_db.insert_generated_regex(record) + self.generated_regex_filter.add(regex_hash) + if status == "rejected" and self.max_stored_rejected_regexes > 0: + self.generated_db.prune_rejected_regexes( + self.max_stored_rejected_regexes + ) + self.generated_regex_filter = self._build_generated_regex_filter() + + def close(self): + self.benign_db.close() + self.generated_db.close() diff --git a/tests/unit/modules/regex_generator/test_regex_generator.py b/tests/unit/modules/regex_generator/test_regex_generator.py new file mode 100644 index 0000000000..6611d2319d --- /dev/null +++ b/tests/unit/modules/regex_generator/test_regex_generator.py @@ -0,0 +1,808 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +import time +from unittest.mock import Mock + +from modules.regex_generator.regex_generator import ( + PROMPT_VERSION, + SYSTEM_PROMPT, + TYPE_PROMPTS, +) +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.core.database.sqlite_db.regex_generator_db import ( + REGEX_TYPES, + RegexGeneratorStorage, +) +from tests.module_factory import ModuleFactory + + +def _build_storage_conf( + store_dir: str, + persistent_store_dir: str = "", + seed_benign_samples: bool = True, + store_rejected_regexes: bool = False, + max_stored_rejected_regexes: int = 10000, + enable_local_whitelist: bool = True, + local_whitelist_path: str = "config/whitelist.conf", +): + conf = Mock() + conf.regex_generator_store_dir = Mock(return_value=store_dir) + conf.regex_generator_persistent_store_dir = Mock( + return_value=persistent_store_dir + ) + conf.regex_generator_seed_benign_samples = Mock( + return_value=seed_benign_samples + ) + conf.regex_generator_store_rejected_regexes = Mock( + return_value=store_rejected_regexes + ) + conf.regex_generator_max_stored_rejected_regexes = Mock( + return_value=max_stored_rejected_regexes + ) + conf.enable_local_whitelist = Mock(return_value=enable_local_whitelist) + conf.local_whitelist_path = Mock(return_value=local_whitelist_path) + return conf + + +def test_regex_generator_config_defaults(): + parser = ConfigParser.__new__(ConfigParser) + parser.config = {} + + assert parser.regex_generator_enabled() is False + assert parser.regex_generator_create_log_file() is False + assert parser.regex_generator_generation_interval_seconds() == 5 + assert parser.regex_generator_allowed_backends() == [] + assert parser.regex_generator_llm_temperature() == 1.2 + assert parser.regex_generator_llm_max_tokens() == 80 + assert parser.regex_generator_llm_response_timeout_seconds() == 90 + assert parser.regex_generator_recent_history_size() == 0 + assert parser.regex_generator_max_regex_length() == 180 + assert parser.regex_generator_regex_validation_timeout_seconds() == 2 + assert parser.regex_generator_store_dir() == "output/regex_generator" + assert parser.regex_generator_persistent_store_dir() == "" + assert parser.regex_generator_store_rejected_regexes() is False + assert parser.regex_generator_max_stored_rejected_regexes() == 10000 + assert parser.regex_generator_seed_benign_samples() is True + + +def test_regex_generator_config_sanitization(): + parser = ConfigParser.__new__(ConfigParser) + parser.config = { + "regex_generator": { + "generation_interval_seconds": "bad", + "create_log_file": "true", + "allowed_backends": "local_qwen", + "llm_temperature": "bad", + "llm_max_tokens": "bad", + "llm_response_timeout_seconds": 0, + "recent_history_size": -2, + "max_regex_length": "bad", + "regex_validation_timeout_seconds": "bad", + "type_weights": { + "dns_domain": 0, + "uri": 0, + "filename": 0, + "tls_sni": 0, + "certificate_cn": 0, + }, + "store_dir": "", + "persistent_store_dir": " /tmp/regex-db ", + "store_rejected_regexes": "true", + "max_stored_rejected_regexes": "bad", + "seed_benign_samples": "false", + } + } + + assert parser.regex_generator_generation_interval_seconds() == 5 + assert parser.regex_generator_create_log_file() is True + assert parser.regex_generator_allowed_backends() == [] + assert parser.regex_generator_llm_temperature() == 1.2 + assert parser.regex_generator_llm_max_tokens() == 80 + assert parser.regex_generator_llm_response_timeout_seconds() == 0 + assert parser.regex_generator_recent_history_size() == 0 + assert parser.regex_generator_max_regex_length() == 180 + assert parser.regex_generator_regex_validation_timeout_seconds() == 2 + assert parser.regex_generator_type_weights() == { + "dns_domain": 1, + "uri": 1, + "filename": 1, + "tls_sni": 1, + "certificate_cn": 1, + } + assert parser.regex_generator_store_dir() == "output/regex_generator" + assert parser.regex_generator_persistent_store_dir() == "/tmp/regex-db" + assert parser.regex_generator_store_rejected_regexes() is True + assert parser.regex_generator_max_stored_rejected_regexes() == 10000 + assert parser.regex_generator_seed_benign_samples() is False + + +def test_regex_generator_generation_interval_allows_zero(): + parser = ConfigParser.__new__(ConfigParser) + parser.config = { + "regex_generator": { + "generation_interval_seconds": 0, + } + } + + assert parser.regex_generator_generation_interval_seconds() == 0 + + +def test_choose_regex_type_honors_weights(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.type_weights = { + "dns_domain": 1, + "uri": 0, + "filename": 0, + "tls_sni": 0, + "certificate_cn": 0, + } + + assert regex_generator._choose_regex_type() == "dns_domain" + + +def test_select_backend_prefers_allowed_backends(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.allowed_backends = ["local_qwen", "openai_default"] + + backend = regex_generator._select_backend( + { + "default_backend": "openai_default", + "backends": { + "openai_default": {"provider": "openai", "model": "gpt-4o-mini"}, + "local_qwen": {"provider": "ollama", "model": "qwen2.5:3b"}, + }, + } + ) + + assert backend == "local_qwen" + + +def test_select_backend_falls_back_to_default_backend(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.allowed_backends = [] + + backend = regex_generator._select_backend( + { + "default_backend": "local_qwen", + "backends": { + "local_qwen": {"provider": "ollama", "model": "qwen2.5:3b"}, + }, + } + ) + + assert backend == "local_qwen" + + +def test_main_waits_when_no_runtime_ready_backend(tmp_path, mocker): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + mocker.patch( + "modules.regex_generator.regex_generator.utils.drop_root_privs_permanently" + ) + mocker.patch("modules.regex_generator.regex_generator.time.sleep") + regex_generator.pre_main() + regex_generator.db.get_available_llm_backends = Mock( + return_value={"default_backend": "", "backends": {}} + ) + regex_generator.next_generation_at = 0 + + regex_generator.main() + + regex_generator.db.publish.assert_not_called() + assert regex_generator.next_generation_at > 0 + regex_generator.shutdown_gracefully() + + +def test_create_log_file_writes_progress_log(tmp_path, mocker): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.output_dir = str(tmp_path / "output") + regex_generator.log_file_path = str(tmp_path / "output" / "regex_generator.log") + regex_generator.create_log_file = True + mocker.patch( + "modules.regex_generator.regex_generator.utils.drop_root_privs_permanently" + ) + + regex_generator.pre_main() + regex_generator._log_detail("test log line") + + with open(regex_generator.log_file_path, "r", encoding="utf-8") as log_file: + log_contents = log_file.read() + + assert "RegexGenerator module ready." in log_contents + assert "test log line" in log_contents + regex_generator.shutdown_gracefully() + + +def test_log_file_rotates_with_global_rotation_settings(tmp_path, mocker): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + output_dir = tmp_path / "output" + regex_generator.output_dir = str(output_dir) + regex_generator.log_file_path = str(output_dir / "regex_generator.log") + regex_generator.create_log_file = True + regex_generator.enable_log_rotation = True + regex_generator.log_rotation_period = 1 + regex_generator.last_log_rotation_time = time.time() - 10 + mocker.patch( + "modules.regex_generator.regex_generator.utils.drop_root_privs_permanently" + ) + + regex_generator.pre_main() + with open(regex_generator.log_file_path, "a", encoding="utf-8") as log_file: + log_file.write("old line\n") + regex_generator.last_log_rotation_time = time.time() - 10 + + regex_generator._log_detail("new line") + + rotated_logs = list(output_dir.glob("regex_generator.log.*")) + assert rotated_logs + with open(regex_generator.log_file_path, "r", encoding="utf-8") as log_file: + log_contents = log_file.read() + assert "new line" in log_contents + regex_generator.shutdown_gracefully() + + +def test_build_prompt_messages_uses_type_specific_prompt(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = Mock() + + messages = regex_generator._build_prompt_messages("dns_domain", "nonce-1") + + assert messages[0]["content"] == SYSTEM_PROMPT + assert TYPE_PROMPTS["dns_domain"] in messages[1]["content"] + assert PROMPT_VERSION in messages[1]["content"] + regex_generator.storage.get_recent_history.assert_not_called() + regex_generator.storage.get_benign_examples.assert_not_called() + + +def test_send_generation_request_publishes_expected_payload(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = Mock() + + regex_generator._send_generation_request("dns_domain", "local_qwen") + + channel, payload = regex_generator.db.publish.call_args.args + request = json.loads(payload) + assert channel == "llm_request" + assert request["backend"] == "local_qwen" + assert request["temperature"] == 1.2 + assert request["max_tokens"] == 80 + assert request["metadata"]["regex_type"] == "dns_domain" + assert request["metadata"]["prompt_version"] == PROMPT_VERSION + assert request["request_id"].startswith("RegexGenerator-") + + +def test_handle_pending_response_matches_by_request_id(tmp_path, mocker): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.pending_request = { + "request_id": "req-1", + "regex_type": "dns_domain", + "backend": "local_qwen", + "sent_at": time.time(), + } + regex_generator._finalize_request = Mock() + regex_generator.get_msg = Mock( + return_value={ + "data": json.dumps( + { + "request_id": "other-req", + "success": True, + "text": "^abc$", + } + ) + } + ) + + regex_generator._handle_pending_response(time.time()) + + regex_generator._finalize_request.assert_not_called() + assert regex_generator.pending_request["request_id"] == "req-1" + + +def test_handle_pending_response_keeps_waiting_after_soft_timeout( + tmp_path, mocker +): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + mocker.patch("modules.regex_generator.regex_generator.time.sleep") + regex_generator.pending_request = { + "request_id": "req-1", + "regex_type": "dns_domain", + "backend": "local_qwen", + "sent_at": time.time() - 120, + "last_warning_at": 0.0, + } + regex_generator.get_msg = Mock(return_value=None) + + regex_generator._handle_pending_response(time.time()) + + assert regex_generator.pending_request["request_id"] == "req-1" + assert regex_generator.pending_request["last_warning_at"] > 0 + regex_generator.print.assert_called() + + +def test_extract_regex_from_llm_text_rejects_invalid_payloads(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + + assert regex_generator._extract_regex_from_llm_text("not json") == ( + "", + "invalid_response", + ) + assert regex_generator._extract_regex_from_llm_text('{"rationale":"x"}') == ( + "", + "missing_regex", + ) + + +def test_extract_regex_from_llm_text_accepts_raw_regex_line(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + + regex, error = regex_generator._extract_regex_from_llm_text( + r"^xqz[a-z0-9]{8,12}\.invalid$" + ) + + assert error is None + assert regex == r"^xqz[a-z0-9]{8,12}\.invalid$" + + +def test_extract_regex_from_llm_text_accepts_fenced_json(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + + regex, error = regex_generator._extract_regex_from_llm_text( + '```json\n{"regex":"^abc$","rationale":"ok"}\n```' + ) + + assert error is None + assert regex == "^abc$" + + +def test_extract_regex_from_llm_text_accepts_embedded_json_object(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + + regex, error = regex_generator._extract_regex_from_llm_text( + 'Here is the result: {"regex":"^abc$","rationale":"ok"}' + ) + + assert error is None + assert regex == "^abc$" + + +def test_validate_regex_rejects_unsupported_or_too_broad_patterns(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + + assert regex_generator._validate_regex(".*") == "regex_too_broad" + assert ( + regex_generator._validate_regex("(?<=abc)def") + == "unsupported_lookbehind" + ) + assert ( + regex_generator._validate_regex(r"^(abc)\1$") + == "unsupported_backreference" + ) + assert ( + regex_generator._validate_regex(r"^(.*a)+$") + == "nested_wildcards" + ) + + +def test_validate_and_store_regex_rejects_duplicate_exact(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = Mock() + regex_generator.storage.might_have_generated_regex.return_value = True + regex_generator.storage.get_existing_generated_regex.return_value = { + "regex": "^dup$" + } + + regex_generator._validate_and_store_regex( + { + "regex_type": "dns_domain", + "regex": "^dup$", + "regex_hash": regex_generator._hash_regex("^dup$"), + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-1", + "created_at": time.time(), + } + ) + + regex_generator.storage.store_generated_regex.assert_not_called() + + +def test_validate_and_store_regex_rejects_validation_timeout(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = Mock() + regex_generator.storage.might_have_generated_regex.return_value = False + regex_generator._find_matching_benign_value = Mock( + side_effect=TimeoutError("timed out") + ) + + regex_generator._validate_and_store_regex( + { + "regex_type": "dns_domain", + "regex": r"^slow-example$", + "regex_hash": regex_generator._hash_regex(r"^slow-example$"), + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-timeout", + "created_at": time.time(), + } + ) + + stored = regex_generator.storage.store_generated_regex.call_args.args[0] + assert stored["status"] == "rejected" + assert stored["rejection_reason"] == "regex_validation_timeout" + + +def test_benign_seeding_initializes_all_types(tmp_path): + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf(str(tmp_path / "regex_generator")), + "dummy_output_dir", + 12345, + ) + + for regex_type in REGEX_TYPES: + assert storage.get_benign_examples(regex_type, limit=1) + + storage.close() + + +def test_storage_resolves_relative_store_dir_inside_run_output_dir(tmp_path): + output_dir = tmp_path / "slips_run_output" + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf("output/regex_generator"), + str(output_dir), + 12345, + ) + + assert storage.store_dir == str(output_dir / "regex_generator") + storage.close() + + +def test_storage_prefers_persistent_store_dir_when_configured(tmp_path): + output_dir = tmp_path / "slips_run_output" + persistent_dir = tmp_path / "persistent_regex_generator" + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf( + "output/regex_generator", + persistent_store_dir=str(persistent_dir), + ), + str(output_dir), + 12345, + ) + + assert storage.store_dir == str(persistent_dir) + storage.close() + + +def test_storage_imports_whitelist_domains_into_matching_regex_types(tmp_path): + whitelist_path = tmp_path / "whitelist.conf" + whitelist_path.write_text( + '\n'.join( + [ + '; comment', + 'domain,example.com,both,alerts', + 'domain,api.github.com,both,alerts', + 'ip,1.2.3.4,both,alerts', + ] + ), + encoding="utf-8", + ) + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf( + str(tmp_path / "regex_generator"), + local_whitelist_path=str(whitelist_path), + ), + "dummy_output_dir", + 12345, + ) + + assert "example.com" in storage.get_benign_examples("dns_domain", limit=100) + assert "example.com" in storage.get_benign_examples("tls_sni", limit=100) + assert "example.com" in storage.get_benign_examples( + "certificate_cn", limit=100 + ) + assert "github.com" in storage.get_benign_examples("dns_domain", limit=100) + assert "/index.html" in storage.get_benign_examples("uri", limit=100) + storage.close() + + +def test_storage_skips_whitelist_import_when_disabled(tmp_path): + whitelist_path = tmp_path / "whitelist.conf" + whitelist_path.write_text( + 'domain,example.com,both,alerts\n', + encoding="utf-8", + ) + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf( + str(tmp_path / "regex_generator"), + enable_local_whitelist=False, + local_whitelist_path=str(whitelist_path), + ), + "dummy_output_dir", + 12345, + ) + + assert "example.com" not in storage.get_benign_examples( + "dns_domain", limit=100 + ) + storage.close() + + +def test_benign_corpus_scan_rejects_matching_regex(tmp_path): + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf( + str(tmp_path / "regex_generator"), + store_rejected_regexes=True, + ), + "dummy_output_dir", + 12345, + ) + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = storage + + regex_generator._validate_and_store_regex( + { + "regex_type": "dns_domain", + "regex": r"^google\.com$", + "regex_hash": regex_generator._hash_regex(r"^google\.com$"), + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-1", + "created_at": time.time(), + } + ) + + rejected = storage.get_generated_regexes( + regex_type="dns_domain", + status="rejected", + ) + assert rejected[0]["rejection_reason"] == "matched_benign_data" + assert rejected[0]["matched_benign_value"] == "google.com" + storage.close() + + +def test_benign_corpus_scan_rejects_regex_matching_whitelist_domain(tmp_path): + whitelist_path = tmp_path / "whitelist.conf" + whitelist_path.write_text( + 'domain,example.com,both,alerts\n', + encoding="utf-8", + ) + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf( + str(tmp_path / "regex_generator"), + store_rejected_regexes=True, + local_whitelist_path=str(whitelist_path), + ), + "dummy_output_dir", + 12345, + ) + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = storage + + regex_generator._validate_and_store_regex( + { + "regex_type": "dns_domain", + "regex": r"^example\.com$", + "regex_hash": regex_generator._hash_regex(r"^example\.com$"), + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-whitelist", + "created_at": time.time(), + } + ) + + rejected = storage.get_generated_regexes( + regex_type="dns_domain", + status="rejected", + ) + assert rejected[0]["rejection_reason"] == "matched_benign_data" + assert rejected[0]["matched_benign_value"] == "example.com" + storage.close() + + +def test_rejected_regexes_are_not_persisted_by_default(tmp_path): + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf(str(tmp_path / "regex_generator")), + "dummy_output_dir", + 12345, + ) + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = storage + + regex_generator._validate_and_store_regex( + { + "regex_type": "dns_domain", + "regex": r"^google\.com$", + "regex_hash": regex_generator._hash_regex(r"^google\.com$"), + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-default-reject", + "created_at": time.time(), + } + ) + + assert storage.get_generated_regexes(status="rejected") == [] + assert storage.was_rejected_in_current_run( + regex_generator._hash_regex(r"^google\.com$") + ) + storage.close() + + +def test_stored_rejected_regexes_are_pruned_to_max_size(tmp_path): + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf( + str(tmp_path / "regex_generator"), + store_rejected_regexes=True, + max_stored_rejected_regexes=1, + ), + "dummy_output_dir", + 12345, + ) + + storage.store_generated_regex( + { + "regex_type": "dns_domain", + "regex": "^first$", + "regex_hash": "hash-first", + "status": "rejected", + "rejection_reason": "invalid_regex_syntax", + "matched_benign_value": None, + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-first", + "created_at": 1.0, + } + ) + storage.store_generated_regex( + { + "regex_type": "dns_domain", + "regex": "^second$", + "regex_hash": "hash-second", + "status": "rejected", + "rejection_reason": "invalid_regex_syntax", + "matched_benign_value": None, + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-second", + "created_at": 2.0, + } + ) + + rejected = storage.get_generated_regexes(status="rejected") + assert [row["regex"] for row in rejected] == ["^second$"] + storage.close() + + +def test_validate_and_store_regex_accepts_non_matching_regex(tmp_path): + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf(str(tmp_path / "regex_generator")), + "dummy_output_dir", + 12345, + ) + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = storage + + regex_generator._validate_and_store_regex( + { + "regex_type": "dns_domain", + "regex": r"^xqz[a-z0-9]{8,12}\.invalid$", + "regex_hash": regex_generator._hash_regex( + r"^xqz[a-z0-9]{8,12}\.invalid$" + ), + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-2", + "created_at": time.time(), + } + ) + + accepted = storage.get_generated_regexes( + regex_type="dns_domain", + status="accepted", + ) + assert accepted[0]["regex"] == r"^xqz[a-z0-9]{8,12}\.invalid$" + storage.close() + + +def test_storage_generated_regex_bloom_filter_tracks_inserted_hash(tmp_path): + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf(str(tmp_path / "regex_generator")), + "dummy_output_dir", + 12345, + ) + + record = { + "regex_type": "dns_domain", + "regex": r"^xqz[a-z0-9]{8,12}\.invalid$", + "regex_hash": "hash-1", + "status": "accepted", + "rejection_reason": None, + "matched_benign_value": None, + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-3", + "created_at": time.time(), + } + + assert storage.might_have_generated_regex("hash-1") is False + storage.store_generated_regex(record) + assert storage.might_have_generated_regex("hash-1") is True + storage.close() From 335d658f538e5c744cc2f77c6f25973dd260dea3 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:14:11 +0000 Subject: [PATCH 0039/1100] Add offline coverage estimator for RegexGenerator output This commit introduces a new script, `regex_coverage_report.py`, which estimates the coverage of accepted regexes against various reference populations, including benign, malicious, and observed traffic strings. The script generates a standalone HTML report and a JSON summary, providing insights into the effectiveness of regex patterns generated by the RegexGenerator. --- scripts/regex_coverage_report.py | 1364 ++++++++++++++++++++++++++++++ 1 file changed, 1364 insertions(+) create mode 100644 scripts/regex_coverage_report.py diff --git a/scripts/regex_coverage_report.py b/scripts/regex_coverage_report.py new file mode 100644 index 0000000000..fff08d79a3 --- /dev/null +++ b/scripts/regex_coverage_report.py @@ -0,0 +1,1364 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +# ruff: noqa: E402 +""" +Offline coverage estimator for RegexGenerator output. + +This script reads accepted regexes from a Slips run output directory and +estimates how much of several reference populations they cover: + +- benign corpus stored by RegexGenerator +- malicious TI-derived strings +- observed traffic strings extracted from the same Slips run + +It writes a standalone HTML report and a JSON summary. +""" + +from __future__ import annotations + +import argparse +import json +import random +import re +import signal +import sqlite3 +import sys +import time +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime, timezone +from html import escape +from pathlib import Path +from typing import Iterable +from urllib.parse import urlparse + +try: + import redis +except ImportError: # pragma: no cover - dependency should exist in runtime + redis = None + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.common.slips_utils import utils +from slips_files.core.database.sqlite_db.regex_generator_db import REGEX_TYPES + + +DOMAIN_LIKE_TYPES = ("dns_domain", "tls_sni", "certificate_cn") +TYPE_LABELS = { + "dns_domain": "DNS Domain", + "uri": "URI", + "filename": "Filename", + "tls_sni": "TLS SNI", + "certificate_cn": "Certificate CN", +} + + +@dataclass +class TIStats: + run_redis_port: int + run_redis_available: bool + ti_cache_port: int + ti_cache_db: int + ti_cache_available: bool + loaded_feeds: int + cache_domain_count: int + cache_ip_count: int + cache_ja3_count: int + cache_jarm_count: int + source_files_scanned: int + + +class ProgressTracker: + BAR_WIDTH = 24 + CLEAR_LINE = "\r\033[2K" + RESET = "\033[0m" + CYAN = "\033[36m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + MAGENTA = "\033[35m" + + def __init__(self, total_regexes: int, total_comparisons: int, mode: str): + self.total_regexes = max(1, total_regexes) + self.total_comparisons = max(1, total_comparisons) + self.mode = mode + self.regexes_done = 0 + self.comparisons_done = 0 + self.current_type = "-" + self.start_time = time.monotonic() + + def print_plan(self): + print( + f"🔬 Coverage work estimate: {self.total_regexes} regexes, " + f"{self.total_comparisons} regex/string comparisons", + flush=True, + ) + self._render() + + def advance(self, regex_type: str, regex: str, comparisons: int): + self.regexes_done += 1 + self.comparisons_done += comparisons + self.current_type = regex_type + self._render() + + def finish(self): + self.regexes_done = self.total_regexes + self.comparisons_done = self.total_comparisons + self._render(done=True) + print(flush=True) + + def _render(self, done: bool = False): + regex_ratio = min(1.0, self.regexes_done / self.total_regexes) + filled = int(regex_ratio * self.BAR_WIDTH) + bar = f"{self.GREEN}{'█' * filled}{self.YELLOW}{'░' * (self.BAR_WIDTH - filled)}{self.RESET}" + elapsed = max(0.001, time.monotonic() - self.start_time) + progress_ratio = min(1.0, self.comparisons_done / self.total_comparisons) + if done or progress_ratio >= 1.0: + eta_seconds = 0.0 + else: + eta_seconds = (elapsed / max(progress_ratio, 1e-9)) - elapsed + status = ( + f"{self.CLEAR_LINE}" + f"🧪 {self.MAGENTA}{self.mode}{self.RESET} " + f"{bar} " + f"{regex_ratio * 100:6.2f}% " + f"| regex {self.regexes_done}/{self.total_regexes} " + f"| cmp {self.comparisons_done:,}/{self.total_comparisons:,} " + f"| type {self.CYAN}{TYPE_LABELS.get(self.current_type, self.current_type)}{self.RESET} " + f"| ETA ⏳ {self._format_duration(eta_seconds)}" + ) + print(status, end="", flush=True) + + @staticmethod + def _format_duration(seconds: float) -> str: + total_seconds = max(0, int(seconds)) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + +class _NullTimeout: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, exc_tb): + return False + + +class _SignalTimeout: + def __init__(self, timeout_seconds: float): + self.timeout_seconds = timeout_seconds + self._previous_handler = None + + def __enter__(self): + self._previous_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, self._handle_timeout) + signal.setitimer(signal.ITIMER_REAL, self.timeout_seconds) + return None + + def __exit__(self, exc_type, exc, exc_tb): + signal.setitimer(signal.ITIMER_REAL, 0) + if self._previous_handler is not None: + signal.signal(signal.SIGALRM, self._previous_handler) + return False + + @staticmethod + def _handle_timeout(signum, frame): + raise TimeoutError("regex population match timed out") + + +def timeout_context(timeout_seconds: float): + if timeout_seconds <= 0: + return _NullTimeout() + return _SignalTimeout(timeout_seconds) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate an offline RegexGenerator coverage report." + ) + parser.add_argument( + "--run-output-dir", + required=True, + help=( + "Slips run output directory containing regex_generator/*.sqlite, " + "or a direct regex store directory containing generated_regexes.sqlite " + "and benign_corpus.sqlite." + ), + ) + parser.add_argument( + "--redis-port", + type=int, + default=6379, + help="Redis port used by the Slips run. Default: 6379.", + ) + parser.add_argument( + "--ti-cache-port", + type=int, + default=6379, + help="Redis port of the shared Slips TI cache. Default: 6379.", + ) + parser.add_argument( + "--ti-cache-db", + type=int, + default=1, + help="Redis DB number for the shared Slips TI cache. Default: 1.", + ) + parser.add_argument( + "--output-html", + default="", + help="Path to output HTML report. Default: /regex_generator_coverage_report.html", + ) + parser.add_argument( + "--output-json", + default="", + help="Path to output JSON summary. Default: /regex_generator_coverage_report.json", + ) + parser.add_argument( + "--sample-limit", + type=int, + default=15, + help="Number of example strings to include per report section.", + ) + parser.add_argument( + "--top-regexes", + type=int, + default=20, + help="Number of top regexes to show per type.", + ) + parser.add_argument( + "--match-timeout-seconds", + type=float, + default=0.25, + help=( + "Maximum wall-clock seconds allowed for one regex against one " + "population before it is skipped. Set 0 to disable." + ), + ) + parser.add_argument( + "--max-population-size", + type=int, + default=10000, + help=( + "Maximum number of values evaluated per population and type. " + "Larger populations are sampled deterministically. Set 0 to disable." + ), + ) + parser.add_argument( + "--sampling-ratio", + type=float, + default=0.1, + help=( + "Fraction of each population to evaluate before applying " + "max-population-size. Use values in (0, 1]. Default: 0.1." + ), + ) + parser.add_argument( + "--full-scan", + action="store_true", + help=( + "Disable both population sampling and the size cap, and scan the " + "full benign, observed, and malicious populations." + ), + ) + parser.add_argument( + "--sampling-seed", + type=int, + default=1, + help="Deterministic seed used when sampling large populations.", + ) + return parser.parse_args() + + +def normalize_string(value: str) -> str: + return str(value or "").strip() + + +def normalize_domain(value: str) -> str: + value = normalize_string(value).rstrip(".").lower() + return value + + +def normalize_uri(value: str) -> str: + value = normalize_string(value) + if not value: + return "" + parsed = urlparse(value) + if parsed.scheme and parsed.netloc: + path = parsed.path or "/" + if parsed.query: + return f"{path}?{parsed.query}" + return path + return value + + +def normalize_filename(value: str) -> str: + value = normalize_string(value) + if not value: + return "" + value = value.split("/")[-1] + value = value.split("\\")[-1] + return value.strip() + + +def normalize_cn(value: str) -> str: + value = normalize_string(value) + if not value: + return "" + cn_match = re.search(r"(?:^|,)CN=([^,]+)", value) + if cn_match: + return cn_match.group(1).strip() + return value + + +def add_string(populations: dict[str, set[str]], regex_type: str, value: str): + if regex_type in DOMAIN_LIKE_TYPES: + normalized = normalize_domain(value) + elif regex_type == "uri": + normalized = normalize_uri(value) + elif regex_type == "filename": + normalized = normalize_filename(value) + else: + normalized = normalize_string(value) + + if normalized: + populations[regex_type].add(normalized) + + +def load_regexes(regex_db_path: Path) -> dict[str, list[dict]]: + regexes_by_type = defaultdict(list) + with sqlite3.connect(regex_db_path) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT regex_type, regex, regex_hash, backend_alias, provider, model, + temperature, prompt_version, request_id, created_at + FROM generated_regexes + WHERE status = 'accepted' + ORDER BY created_at ASC + """ + ).fetchall() + + for row in rows: + regexes_by_type[row["regex_type"]].append( + { + "regex": row["regex"], + "regex_hash": row["regex_hash"], + "backend_alias": row["backend_alias"], + "provider": row["provider"], + "model": row["model"], + "temperature": row["temperature"], + "prompt_version": row["prompt_version"], + "request_id": row["request_id"], + "created_at": row["created_at"], + } + ) + + return regexes_by_type + + +def load_benign_corpus(benign_db_path: Path) -> dict[str, set[str]]: + populations = {regex_type: set() for regex_type in REGEX_TYPES} + with sqlite3.connect(benign_db_path) as conn: + for regex_type, value in conn.execute( + "SELECT regex_type, value FROM benign_strings" + ): + add_string(populations, regex_type, value) + return populations + + +def parse_zeek_json_log(path: Path) -> Iterable[dict]: + with path.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + + +def load_observed_populations(run_output_dir: Path) -> dict[str, set[str]]: + populations = {regex_type: set() for regex_type in REGEX_TYPES} + zeek_dir = run_output_dir / "zeek_files" + + dns_log = zeek_dir / "dns.log" + if dns_log.exists(): + for row in parse_zeek_json_log(dns_log): + add_string(populations, "dns_domain", row.get("query", "")) + + http_log = zeek_dir / "http.log" + if http_log.exists(): + for row in parse_zeek_json_log(http_log): + uri = row.get("uri", "") + add_string(populations, "uri", uri) + host = normalize_domain(row.get("host", "")) + if host: + add_string(populations, "dns_domain", host) + filename = filename_from_uri(uri) + if filename: + add_string(populations, "filename", filename) + + ssl_log = zeek_dir / "ssl.log" + if ssl_log.exists(): + for row in parse_zeek_json_log(ssl_log): + server_name = row.get("server_name", "") + add_string(populations, "tls_sni", server_name) + add_string(populations, "dns_domain", server_name) + + x509_log = zeek_dir / "x509.log" + if x509_log.exists(): + for row in parse_zeek_json_log(x509_log): + subject = row.get("certificate.subject", "") + cn = normalize_cn(subject) + add_string(populations, "certificate_cn", cn) + if utils.is_valid_domain(cn): + add_string(populations, "dns_domain", cn) + + files_log = zeek_dir / "files.log" + if files_log.exists(): + for row in parse_zeek_json_log(files_log): + filename = row.get("filename", "") + if filename: + add_string(populations, "filename", filename) + + if all(not values for values in populations.values()): + flow_db = run_output_dir / "flows.sqlite" + if flow_db.exists(): + load_observed_from_flows_sqlite(flow_db, populations) + + return populations + + +def load_observed_from_flows_sqlite( + flows_db_path: Path, populations: dict[str, set[str]] +): + with sqlite3.connect(flows_db_path) as conn: + rows = conn.execute("SELECT flow_type, flow FROM altflows") + for flow_type, flow_json in rows: + try: + flow = json.loads(flow_json) + except json.JSONDecodeError: + continue + + if flow_type == "dns": + add_string(populations, "dns_domain", flow.get("query", "")) + elif flow_type == "http": + uri = flow.get("uri", "") + add_string(populations, "uri", uri) + add_string(populations, "dns_domain", flow.get("host", "")) + filename = filename_from_uri(uri) + if filename: + add_string(populations, "filename", filename) + elif flow_type == "ssl": + add_string( + populations, + "tls_sni", + flow.get("server_name", flow.get("subject", "")), + ) + + +def filename_from_uri(uri: str) -> str: + normalized = normalize_uri(uri) + if not normalized: + return "" + path = normalized.split("?", 1)[0] + filename = normalize_filename(path) + if "." not in filename: + return "" + return filename + + +def load_ti_populations( + run_redis_port: int, + ti_cache_port: int, + ti_cache_db: int, +) -> tuple[dict[str, set[str]], TIStats]: + populations = {regex_type: set() for regex_type in REGEX_TYPES} + config = ConfigParser() + loaded_feeds = 0 + cache_domain_count = 0 + cache_ip_count = 0 + cache_ja3_count = 0 + cache_jarm_count = 0 + run_redis_available = False + ti_cache_available = False + have_cached_domains = False + + if redis is not None: + try: + run_client = redis.Redis( + host="127.0.0.1", + port=run_redis_port, + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + loaded = run_client.get("loaded_TI_files_number") + loaded_feeds = int(loaded or 0) + run_redis_available = True + except Exception: + run_redis_available = False + + try: + cache_client = redis.Redis( + host="127.0.0.1", + port=ti_cache_port, + db=ti_cache_db, + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + redis_domains = cache_client.hkeys("IoC_domains") + cache_domain_count = len(redis_domains) + cache_ip_count = cache_client.hlen("IoC_ips") + cache_ja3_count = cache_client.hlen("IoC_JA3") + cache_jarm_count = cache_client.hlen("IoC_JARM") + ti_cache_available = True + for domain in redis_domains: + domain = normalize_domain(domain) + if not domain: + continue + for regex_type in DOMAIN_LIKE_TYPES: + add_string(populations, regex_type, domain) + add_string(populations, "dns_domain", domain) + have_cached_domains = bool(redis_domains) + except Exception: + ti_cache_available = False + + scanned_files = 0 + if not have_cached_domains: + for file_path in ti_source_files(config): + scanned_files += 1 + populate_ti_strings_from_file(file_path, populations) + else: + for file_path in ti_source_files(config): + scanned_files += 1 + populate_ti_strings_from_file( + file_path, + populations, + add_domains=False, + ) + + return populations, TIStats( + run_redis_port=run_redis_port, + run_redis_available=run_redis_available, + ti_cache_port=ti_cache_port, + ti_cache_db=ti_cache_db, + ti_cache_available=ti_cache_available, + loaded_feeds=loaded_feeds, + cache_domain_count=cache_domain_count, + cache_ip_count=cache_ip_count, + cache_ja3_count=cache_ja3_count, + cache_jarm_count=cache_jarm_count, + source_files_scanned=scanned_files, + ) + + +def ti_source_files(config: ConfigParser) -> Iterable[Path]: + candidates = [ + Path(config.local_ti_data_path()), + Path(config.remote_ti_data_path()), + ] + seen = set() + for base in candidates: + if not base.is_absolute(): + base = Path.cwd() / base + if not base.exists(): + continue + for path in sorted(base.rglob("*")): + if not path.is_file(): + continue + if path.name.startswith("."): + continue + if path.suffix.lower() in {".pyc", ".png", ".jpg", ".jpeg"}: + continue + resolved = str(path.resolve()) + if resolved in seen: + continue + seen.add(resolved) + yield path + + +def populate_ti_strings_from_file( + path: Path, + populations: dict[str, set[str]], + add_domains: bool = True, +) -> None: + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return + + for token in tokenize_ti_text(text): + add_ti_token(token, populations, add_domains=add_domains) + + +def tokenize_ti_text(text: str) -> Iterable[str]: + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + + for token in re.split(r"[\s,\t;\"']+", line): + token = token.strip() + if token: + yield token + + +def add_ti_token( + token: str, + populations: dict[str, set[str]], + add_domains: bool = True, +) -> None: + token = token.strip().strip(",") + if not token: + return + + ioc_type = utils.detect_ioc_type(token) + if ioc_type == "domain" and add_domains: + domain = normalize_domain(token) + for regex_type in DOMAIN_LIKE_TYPES: + add_string(populations, regex_type, domain) + add_string(populations, "dns_domain", domain) + return + + if ioc_type != "url": + return + + parsed = urlparse(token) + domain = normalize_domain(parsed.hostname or "") + if domain and add_domains: + for regex_type in DOMAIN_LIKE_TYPES: + add_string(populations, regex_type, domain) + add_string(populations, "dns_domain", domain) + + uri = normalize_uri(token) + if uri: + add_string(populations, "uri", uri) + filename = filename_from_uri(uri) + if filename: + add_string(populations, "filename", filename) + + +def compile_regexes(regexes_by_type: dict[str, list[dict]]) -> dict[str, list[dict]]: + compiled_by_type = defaultdict(list) + for regex_type, regex_rows in regexes_by_type.items(): + for row in regex_rows: + try: + compiled = re.compile(row["regex"]) + except re.error: + continue + enriched = dict(row) + enriched["compiled"] = compiled + compiled_by_type[regex_type].append(enriched) + return compiled_by_type + + +def sample_population( + values: list[str], + max_population_size: int, + sampling_seed: int, + sampling_ratio: float, +) -> tuple[list[str], int]: + original_total = len(values) + if original_total == 0: + return values, original_total + + target_size = original_total + if 0 < sampling_ratio < 1: + target_size = max(1, int(original_total * sampling_ratio)) + + if max_population_size > 0: + target_size = min(target_size, max_population_size) + + if target_size >= original_total: + return values, original_total + + sampler = random.Random(f"{sampling_seed}:{original_total}") + sampled = sampler.sample(values, target_size) + sampled.sort() + return sampled, original_total + + +def compute_coverage( + compiled_regexes: dict[str, list[dict]], + benign_populations: dict[str, set[str]], + malicious_populations: dict[str, set[str]], + observed_populations: dict[str, set[str]], + match_timeout_seconds: float, + max_population_size: int, + sampling_seed: int, + sampling_ratio: float, + progress: ProgressTracker | None = None, +): + summary = {} + + for regex_type in REGEX_TYPES: + benign_values_all = sorted(benign_populations.get(regex_type, set())) + malicious_values_all = sorted(malicious_populations.get(regex_type, set())) + observed_values_all = sorted(observed_populations.get(regex_type, set())) + reference_union_all = sorted( + set(malicious_values_all).union(observed_values_all) + ) + + benign_values, benign_original_total = sample_population( + benign_values_all, + max_population_size, + sampling_seed, + sampling_ratio, + ) + malicious_values, malicious_original_total = sample_population( + malicious_values_all, + max_population_size, + sampling_seed, + sampling_ratio, + ) + observed_values, observed_original_total = sample_population( + observed_values_all, + max_population_size, + sampling_seed, + sampling_ratio, + ) + reference_union, reference_original_total = sample_population( + reference_union_all, + max_population_size, + sampling_seed, + sampling_ratio, + ) + + population_map = { + "benign": benign_values, + "malicious": malicious_values, + "observed": observed_values, + "reference_union": reference_union, + } + original_totals = { + "benign": benign_original_total, + "malicious": malicious_original_total, + "observed": observed_original_total, + "reference_union": reference_original_total, + } + regex_rows = compiled_regexes.get(regex_type, []) + + overall_matches = {name: set() for name in population_map} + population_timeout_counts = {name: 0 for name in population_map} + regex_details = [] + for row in regex_rows: + detail = { + "regex": row["regex"], + "request_id": row["request_id"], + "matches": {}, + "timed_out_populations": [], + "unique_reference_matches": 0, + "score": 0, + } + compiled = row["compiled"] + comparisons_for_regex = sum(len(values) for values in population_map.values()) + for population_name, values in population_map.items(): + try: + with timeout_context(match_timeout_seconds): + matched = [ + value for value in values if compiled.search(value) + ] + except TimeoutError: + matched = [] + detail["timed_out_populations"].append(population_name) + population_timeout_counts[population_name] += 1 + detail["matches"][population_name] = matched + overall_matches[population_name].update(matched) + + detail["unique_reference_matches"] = len( + set(detail["matches"]["reference_union"]) + ) + detail["score"] = ( + len(detail["matches"]["reference_union"]) + - len(detail["matches"]["benign"]) + ) + regex_details.append(detail) + if progress is not None: + progress.advance( + regex_type, + row["regex"], + comparisons_for_regex, + ) + + regex_details.sort( + key=lambda item: ( + item["score"], + item["unique_reference_matches"], + -len(item["matches"]["benign"]), + ), + reverse=True, + ) + + population_stats = {} + for population_name, values in population_map.items(): + total = len(values) + matched_values = sorted(overall_matches[population_name]) + unmatched_values = [value for value in values if value not in overall_matches[population_name]] + original_total = original_totals[population_name] + population_stats[population_name] = { + "total": total, + "original_total": original_total, + "sampled": total != original_total, + "matched": len(matched_values), + "coverage_ratio": (len(matched_values) / total) if total else None, + "timeout_count": population_timeout_counts[population_name], + "matched_values": matched_values, + "unmatched_values": unmatched_values, + } + + summary[regex_type] = { + "regex_count": len(regex_rows), + "populations": population_stats, + "regex_details": regex_details, + } + + return summary + + +def build_report_payload( + run_output_dir: Path, + regex_db_path: Path, + benign_db_path: Path, + ti_stats: TIStats, + coverage_summary: dict, +): + totals = { + "accepted_regexes": sum( + details["regex_count"] for details in coverage_summary.values() + ), + "types_with_regexes": sum( + 1 for details in coverage_summary.values() if details["regex_count"] + ), + } + generated_at = datetime.now(timezone.utc).isoformat() + return { + "generated_at": generated_at, + "run_output_dir": str(run_output_dir), + "regex_db_path": str(regex_db_path), + "benign_db_path": str(benign_db_path), + "ti": { + "run_redis_port": ti_stats.run_redis_port, + "run_redis_available": ti_stats.run_redis_available, + "ti_cache_port": ti_stats.ti_cache_port, + "ti_cache_db": ti_stats.ti_cache_db, + "ti_cache_available": ti_stats.ti_cache_available, + "loaded_feeds": ti_stats.loaded_feeds, + "cache_domain_count": ti_stats.cache_domain_count, + "cache_ip_count": ti_stats.cache_ip_count, + "cache_ja3_count": ti_stats.cache_ja3_count, + "cache_jarm_count": ti_stats.cache_jarm_count, + "source_files_scanned": ti_stats.source_files_scanned, + }, + "totals": totals, + "types": coverage_summary, + } + + +def ratio_text(value: float | None) -> str: + if value is None: + return "n/a" + percentage = value * 100 + if percentage == 0: + return "0.0%" + + formatted = f"{percentage:.6f}".rstrip("0").rstrip(".") + if "." not in formatted: + formatted = f"{formatted}.0" + return f"{formatted}%" + + +def render_html(report: dict, sample_limit: int, top_regexes: int) -> str: + rows = [] + for regex_type in REGEX_TYPES: + details = report["types"][regex_type] + populations = details["populations"] + rows.append( + f""" + + {escape(TYPE_LABELS[regex_type])} + {details['regex_count']} + {pop_text(populations['reference_union'])} + {pop_text(populations['malicious'])} + {pop_text(populations['observed'])} + {pop_text(populations['benign'])} + + """ + ) + + sections = [] + for regex_type in REGEX_TYPES: + details = report["types"][regex_type] + populations = details["populations"] + regex_rows = details["regex_details"][:top_regexes] + + population_blocks = [] + for population_name in ("reference_union", "malicious", "observed", "benign"): + stats = populations[population_name] + label = { + "reference_union": "Reference Union", + "malicious": "Malicious TI", + "observed": "Observed Traffic", + "benign": "Benign Corpus", + }[population_name] + population_blocks.append( + f""" +
+

{escape(label)}

+

{stats['matched']} matched out of {stats['total']} values

+

Coverage: {ratio_text(stats['coverage_ratio'])}

+

Sampled population: {str(stats['sampled']).lower()}{f", original total {stats['original_total']}" if stats['sampled'] else ""}

+

Timed-out regex checks: {stats['timeout_count']}

+

Matched samples: {sample_list(stats['matched_values'], sample_limit)}

+

Unmatched samples: {sample_list(stats['unmatched_values'], sample_limit)}

+
+ """ + ) + + regex_table_rows = [] + for row in regex_rows: + regex_table_rows.append( + f""" + + {escape(row['regex'])} + {len(row['matches']['reference_union'])} + {len(row['matches']['malicious'])} + {len(row['matches']['observed'])} + {len(row['matches']['benign'])} + {row['score']} + {len(row['timed_out_populations'])} + + """ + ) + + sections.append( + f""" +
+

{escape(TYPE_LABELS[regex_type])}

+
+ {''.join(population_blocks)} +
+

Top Regexes

+ + + + + + + + + + + + + + {''.join(regex_table_rows) or ''} + +
RegexReference UnionMalicious TIObservedBenignScoreTimeouts
No accepted regexes.
+
+ """ + ) + + ti = report["ti"] + glossary = """ +
+

How To Read This

+
+
+

Accepted Regexes

+

+ The number of regexes currently stored as accepted for that type. +

+
+
+

Reference Union

+

+ The union of Malicious TI and Observed Traffic + for that type. It answers: how much of the combined malicious and seen-in-this-run + population is covered by the regex set. +

+
+
+

Malicious TI

+

+ Strings derived from Slips threat-intelligence data. For domain-like types this mainly + comes from the TI cache. For URI and filename it may also come from parsed TI files. +

+
+
+

Observed

+

+ Strings extracted from the selected run itself, using Zeek logs or flows.sqlite. + This is not necessarily malicious. It is the local seen population for that run. +

+
+
+

Benign Spillover

+

+ Matches against the benign corpus. Lower is better. High benign spillover means the + regex set is too broad for that type. +

+
+
+

Coverage Numbers

+

+ Values are shown as matched / total (percent). If a population was sampled, + the report says so explicitly and the percentage is over the sampled population, not the + full original population. +

+
+
+

Timeouts

+

+ Some regexes are expensive to evaluate. A timeout means the report skipped that regex for + that population instead of hanging forever. +

+
+
+

Top Regexes Score

+

+ The score is currently reference_union_matches - benign_matches. It is a rough + usefulness ranking, not a formal quality metric. +

+
+
+
+ """ + return f""" + + + + Regex Coverage Report + + + +
+
+

Regex Coverage Report

+

+ Offline estimate of accepted RegexGenerator coverage against three reference populations: + benign corpus, TI-derived malicious strings, and observed traffic from the selected Slips run. +

+
+
+

Run

+

{escape(report['run_output_dir'])}

+

Generated at {escape(report['generated_at'])}

+
+
+

Regexes

+

{report['totals']['accepted_regexes']} accepted regexes

+

{report['totals']['types_with_regexes']} types currently populated

+
+
+

Threat Intelligence

+

Run Redis {ti['run_redis_port']}, TI cache {ti['ti_cache_port']}/{ti['ti_cache_db']}

+

Run Redis: {str(ti['run_redis_available']).lower()}, TI cache: {str(ti['ti_cache_available']).lower()}

+

Loaded feeds: {ti['loaded_feeds']}, cached domains: {ti['cache_domain_count']}, cached IPs: {ti['cache_ip_count']}, JA3: {ti['cache_ja3_count']}, JARM: {ti['cache_jarm_count']}

+

Supplemental TI files scanned for URL and filename extraction: {ti['source_files_scanned']}

+
+
+

Databases

+

Regex DB: {escape(report['regex_db_path'])}

+

Benign DB: {escape(report['benign_db_path'])}

+
+
+
+ +

Coverage by Type

+ + + + + + + + + + + + + {''.join(rows)} + +
TypeAccepted RegexesReference UnionMalicious TIObservedBenign Spillover
+ + {glossary} + + {''.join(sections)} +
+ + +""" + + +def pop_text(stats: dict) -> str: + summary = ( + f"{stats['matched']}/{stats['total']} " + f"({ratio_text(stats['coverage_ratio'])})" + ) + if stats.get("sampled"): + return f"{summary}, sample of {stats['original_total']}" + return summary + + +def sample_list(values: list[str], limit: int) -> str: + if not values: + return "none" + values = values[:limit] + return ", ".join(f"{escape(value)}" for value in values) + + +def ensure_paths( + args: argparse.Namespace, +) -> tuple[Path, Path, Path, Path, Path]: + input_path = Path(args.run_output_dir).expanduser().resolve() + + store_dir_candidate = input_path + direct_regex_db_path = store_dir_candidate / "generated_regexes.sqlite" + direct_benign_db_path = store_dir_candidate / "benign_corpus.sqlite" + nested_regex_db_path = ( + input_path / "regex_generator" / "generated_regexes.sqlite" + ) + nested_benign_db_path = input_path / "regex_generator" / "benign_corpus.sqlite" + + if direct_regex_db_path.exists() and direct_benign_db_path.exists(): + run_output_dir = input_path + regex_db_path = direct_regex_db_path + benign_db_path = direct_benign_db_path + elif nested_regex_db_path.exists() and nested_benign_db_path.exists(): + run_output_dir = input_path + regex_db_path = nested_regex_db_path + benign_db_path = nested_benign_db_path + else: + raise FileNotFoundError( + "Could not find regex SQLite files. Expected either:\n" + f"- {direct_regex_db_path} and {direct_benign_db_path}\n" + f"- {nested_regex_db_path} and {nested_benign_db_path}" + ) + + output_html = ( + Path(args.output_html).expanduser().resolve() + if args.output_html + else run_output_dir / "regex_generator_coverage_report.html" + ) + output_json = ( + Path(args.output_json).expanduser().resolve() + if args.output_json + else run_output_dir / "regex_generator_coverage_report.json" + ) + output_html.parent.mkdir(parents=True, exist_ok=True) + output_json.parent.mkdir(parents=True, exist_ok=True) + return run_output_dir, regex_db_path, benign_db_path, output_html, output_json + + +def main(): + args = parse_args() + if args.sampling_ratio <= 0 or args.sampling_ratio > 1: + raise ValueError("--sampling-ratio must be greater than 0 and less than or equal to 1") + if args.full_scan: + args.max_population_size = 0 + args.sampling_ratio = 1.0 + + ( + run_output_dir, + regex_db_path, + benign_db_path, + output_html, + output_json, + ) = ensure_paths(args) + + regexes_by_type = load_regexes(regex_db_path) + benign_populations = load_benign_corpus(benign_db_path) + observed_populations = load_observed_populations(run_output_dir) + malicious_populations, ti_stats = load_ti_populations( + args.redis_port, + args.ti_cache_port, + args.ti_cache_db, + ) + compiled_regexes = compile_regexes(regexes_by_type) + total_regexes = sum(len(rows) for rows in compiled_regexes.values()) + sampled_benign = { + regex_type: sample_population( + sorted(benign_populations.get(regex_type, set())), + args.max_population_size, + args.sampling_seed, + args.sampling_ratio, + )[0] + for regex_type in REGEX_TYPES + } + sampled_malicious = { + regex_type: sample_population( + sorted(malicious_populations.get(regex_type, set())), + args.max_population_size, + args.sampling_seed, + args.sampling_ratio, + )[0] + for regex_type in REGEX_TYPES + } + sampled_observed = { + regex_type: sample_population( + sorted(observed_populations.get(regex_type, set())), + args.max_population_size, + args.sampling_seed, + args.sampling_ratio, + )[0] + for regex_type in REGEX_TYPES + } + total_comparisons = 0 + for regex_type in REGEX_TYPES: + reference_union = set(sampled_malicious[regex_type]).union( + sampled_observed[regex_type] + ) + comparisons_per_regex = ( + len(sampled_benign[regex_type]) + + len(sampled_malicious[regex_type]) + + len(sampled_observed[regex_type]) + + len(reference_union) + ) + total_comparisons += comparisons_per_regex * len( + compiled_regexes.get(regex_type, []) + ) + + mode = "full scan" if args.full_scan else "sampled estimate" + progress = ProgressTracker(total_regexes, total_comparisons, mode) + print( + f"Starting coverage report in {mode} mode. " + f"match_timeout_seconds={args.match_timeout_seconds}, " + f"max_population_size={args.max_population_size}, " + f"sampling_ratio={args.sampling_ratio}", + flush=True, + ) + progress.print_plan() + coverage_summary = compute_coverage( + compiled_regexes, + benign_populations, + malicious_populations, + observed_populations, + args.match_timeout_seconds, + args.max_population_size, + args.sampling_seed, + args.sampling_ratio, + progress, + ) + progress.finish() + report = build_report_payload( + run_output_dir, + regex_db_path, + benign_db_path, + ti_stats, + coverage_summary, + ) + + output_html.write_text( + render_html(report, args.sample_limit, args.top_regexes), + encoding="utf-8", + ) + output_json.write_text(json.dumps(report, indent=2), encoding="utf-8") + + print(f"HTML report written to {output_html}") + print(f"JSON summary written to {output_json}") + + +if __name__ == "__main__": + main() From 084c119b61a078eac8875afd35106a090a16f0d9 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:14:25 +0000 Subject: [PATCH 0040/1100] feat: update sampling options in regex generator documentation --- docs/regex_generator_module.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/regex_generator_module.md b/docs/regex_generator_module.md index dd8a83bc79..e224dfbf65 100644 --- a/docs/regex_generator_module.md +++ b/docs/regex_generator_module.md @@ -227,7 +227,7 @@ By default, large populations are sampled so the script finishes in practical time. It prints terminal progress while it runs, for example: ```text -[37/752] type=dns_domain comparisons=742257/8547616 regex=... +🧪 sampled estimate ███████░░░░░░░░░░░░ 31.62% | regex 247/781 | cmp 560,840/1,770,991 | type DNS Domain | ETA ⏳ 00:00:14 ``` If you want the exhaustive run for research, use: @@ -243,8 +243,9 @@ If you want the exhaustive run for research, use: Useful knobs: -- `--full-scan`: disable sampling and scan the full populations. -- `--max-population-size`: sample cap for each population/type in estimate mode. +- `--sampling-ratio`: fractional sample of each population in estimate mode. Default: `0.1`. +- `--max-population-size`: hard cap applied after `--sampling-ratio` in estimate mode. +- `--full-scan`: disable both sampling and the size cap, and scan the full populations. - `--match-timeout-seconds`: per-regex/per-population timeout guard. The script writes: From 8fe36ba170540c73f47529357d9fb4ee61da1c42 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:14:41 +0000 Subject: [PATCH 0041/1100] feat: update sampling options in regex generator documentation --- modules/regex_generator/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/regex_generator/README.md b/modules/regex_generator/README.md index a5bd6d2ee3..5ee1a97536 100644 --- a/modules/regex_generator/README.md +++ b/modules/regex_generator/README.md @@ -217,7 +217,7 @@ By default, large populations are sampled so the script finishes in practical time. It prints terminal progress while it runs, for example: ```text -[37/752] type=dns_domain comparisons=742257/8547616 regex=... +🧪 sampled estimate ███████░░░░░░░░░░░░ 31.62% | regex 247/781 | cmp 560,840/1,770,991 | type DNS Domain | ETA ⏳ 00:00:14 ``` If you want the exhaustive run for research, use: @@ -233,8 +233,9 @@ If you want the exhaustive run for research, use: Useful knobs: -- `--full-scan`: disable sampling and scan the full populations. -- `--max-population-size`: sample cap for each population/type in estimate mode. +- `--sampling-ratio`: fractional sample of each population in estimate mode. Default: `0.1`. +- `--max-population-size`: hard cap applied after `--sampling-ratio` in estimate mode. +- `--full-scan`: disable both sampling and the size cap, and scan the full populations. - `--match-timeout-seconds`: per-regex/per-population timeout guard. This generates: From a8a6491ad7ed4d8e16155d356045a9f54ac9c188 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:47:31 +0000 Subject: [PATCH 0042/1100] docs: enhance progress line details and clarify sampling options in regex generator documentation --- docs/regex_generator_module.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/regex_generator_module.md b/docs/regex_generator_module.md index e224dfbf65..ff5e507294 100644 --- a/docs/regex_generator_module.md +++ b/docs/regex_generator_module.md @@ -230,6 +230,11 @@ time. It prints terminal progress while it runs, for example: 🧪 sampled estimate ███████░░░░░░░░░░░░ 31.62% | regex 247/781 | cmp 560,840/1,770,991 | type DNS Domain | ETA ⏳ 00:00:14 ``` +In that progress line: + +- `regex 247/781` means 247 accepted regexes have been evaluated out of 781 total accepted regexes. +- `cmp 560,840/1,770,991` means regex-versus-string match operations, not raw TI entries. The number grows because many regexes are checked against many strings across the benign corpus, malicious TI, observed traffic, and reference-union populations. + If you want the exhaustive run for research, use: ```bash @@ -243,10 +248,10 @@ If you want the exhaustive run for research, use: Useful knobs: -- `--sampling-ratio`: fractional sample of each population in estimate mode. Default: `0.1`. -- `--max-population-size`: hard cap applied after `--sampling-ratio` in estimate mode. -- `--full-scan`: disable both sampling and the size cap, and scan the full populations. -- `--match-timeout-seconds`: per-regex/per-population timeout guard. +- `--sampling-ratio`: fraction of strings to evaluate from each regex-type population in estimate mode. This is applied separately to the benign corpus values, malicious TI values, observed traffic values, and reference-union values. Default: `0.1`. +- `--max-population-size`: hard cap on the number of strings evaluated for each regex type inside each population, after `--sampling-ratio` is applied. +- `--full-scan`: disable both `--sampling-ratio` and `--max-population-size`, and scan all strings in all populations for every regex type. +- `--match-timeout-seconds`: timeout for one regex tested against one regex-type population of strings. The script writes: @@ -257,8 +262,9 @@ inside the selected run output directory. The estimate is based on: -- the RegexGenerator benign corpus DB -- TI-derived malicious reference strings from Redis and TI cache files -- observed traffic strings from Zeek logs or `flows.sqlite` +- the RegexGenerator benign corpus DB, grouped by regex type +- TI-derived malicious reference strings from Redis and TI cache files, grouped by regex type +- observed traffic strings from Zeek logs or `flows.sqlite`, grouped by regex type +- the per-type reference union, which is `malicious TI ∪ observed traffic` This is an offline report only. It does not run continuously inside Slips. From 99f5a5ddb273670ca611e286684107b8a0eeafb1 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:48:28 +0000 Subject: [PATCH 0043/1100] docs: clarify progress line details and enhance sampling options descriptions in README --- modules/regex_generator/README.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/modules/regex_generator/README.md b/modules/regex_generator/README.md index 5ee1a97536..f3ed14507b 100644 --- a/modules/regex_generator/README.md +++ b/modules/regex_generator/README.md @@ -220,6 +220,11 @@ time. It prints terminal progress while it runs, for example: 🧪 sampled estimate ███████░░░░░░░░░░░░ 31.62% | regex 247/781 | cmp 560,840/1,770,991 | type DNS Domain | ETA ⏳ 00:00:14 ``` +In that progress line: + +- `regex 247/781` means 247 accepted regexes have been evaluated out of 781 total accepted regexes. +- `cmp 560,840/1,770,991` means regex-versus-string match operations, not raw TI entries. The number grows because many regexes are checked against many strings across the benign corpus, malicious TI, observed traffic, and reference-union populations. + If you want the exhaustive run for research, use: ```bash @@ -233,10 +238,10 @@ If you want the exhaustive run for research, use: Useful knobs: -- `--sampling-ratio`: fractional sample of each population in estimate mode. Default: `0.1`. -- `--max-population-size`: hard cap applied after `--sampling-ratio` in estimate mode. -- `--full-scan`: disable both sampling and the size cap, and scan the full populations. -- `--match-timeout-seconds`: per-regex/per-population timeout guard. +- `--sampling-ratio`: fraction of strings to evaluate from each regex-type population in estimate mode. This is applied separately to the benign corpus values, malicious TI values, observed traffic values, and reference-union values. Default: `0.1`. +- `--max-population-size`: hard cap on the number of strings evaluated for each regex type inside each population, after `--sampling-ratio` is applied. +- `--full-scan`: disable both `--sampling-ratio` and `--max-population-size`, and scan all strings in all populations for every regex type. +- `--match-timeout-seconds`: timeout for one regex tested against one regex-type population of strings. This generates: @@ -247,10 +252,10 @@ inside the selected run output directory. The report estimates coverage against: -- the local benign corpus DB -- TI-derived malicious reference strings from Redis and TI files -- observed traffic strings from the same run, taken from Zeek logs or - `flows.sqlite` +- the local benign corpus DB, grouped by regex type +- TI-derived malicious reference strings from Redis and TI files, grouped by regex type +- observed traffic strings from the same run, grouped by regex type and taken from Zeek logs or `flows.sqlite` +- the per-type reference union, which is `malicious TI ∪ observed traffic` The report is offline only. It is not part of the continuous RegexGenerator loop. From c1cba50ccfd0f7b87a8abeb5d7eb399f43ed5dff Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 18:48:37 +0000 Subject: [PATCH 0044/1100] docs: enhance documentation for regex coverage report parameters and output details --- scripts/regex_coverage_report.py | 62 ++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/scripts/regex_coverage_report.py b/scripts/regex_coverage_report.py index fff08d79a3..9c65681aeb 100644 --- a/scripts/regex_coverage_report.py +++ b/scripts/regex_coverage_report.py @@ -93,7 +93,8 @@ def __init__(self, total_regexes: int, total_comparisons: int, mode: str): def print_plan(self): print( f"🔬 Coverage work estimate: {self.total_regexes} regexes, " - f"{self.total_comparisons} regex/string comparisons", + f"{self.total_comparisons} planned regex/string comparisons " + "(not raw TI entries)", flush=True, ) self._render() @@ -235,7 +236,10 @@ def parse_args() -> argparse.Namespace: default=0.25, help=( "Maximum wall-clock seconds allowed for one regex against one " - "population before it is skipped. Set 0 to disable." + "population of strings for one regex type before it is skipped. " + "The populations are: benign corpus values, malicious TI values, " + "observed traffic values, and the reference union of malicious+observed. " + "Set 0 to disable." ), ) parser.add_argument( @@ -243,8 +247,11 @@ def parse_args() -> argparse.Namespace: type=int, default=10000, help=( - "Maximum number of values evaluated per population and type. " - "Larger populations are sampled deterministically. Set 0 to disable." + "Maximum number of strings evaluated for each regex type inside each " + "population: benign corpus, malicious TI, observed traffic, and " + "reference union. This cap is applied after --sampling-ratio. " + "Larger populations are sampled deterministically. Set 0 to disable " + "the cap." ), ) parser.add_argument( @@ -252,16 +259,19 @@ def parse_args() -> argparse.Namespace: type=float, default=0.1, help=( - "Fraction of each population to evaluate before applying " - "max-population-size. Use values in (0, 1]. Default: 0.1." + "Fraction of strings to evaluate from each regex-type population " + "before applying --max-population-size. This is applied separately " + "to benign corpus values, malicious TI values, observed traffic values, " + "and reference-union values. Use values in (0, 1]. Default: 0.1." ), ) parser.add_argument( "--full-scan", action="store_true", help=( - "Disable both population sampling and the size cap, and scan the " - "full benign, observed, and malicious populations." + "Disable both --sampling-ratio and --max-population-size, and scan " + "all strings in all populations for every regex type: benign corpus, " + "malicious TI, observed traffic, and reference union." ), ) parser.add_argument( @@ -949,12 +959,12 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str: Regex - Reference Union - Malicious TI - Observed - Benign - Score - Timeouts + Reference Union + Malicious TI + Observed + Benign + Score + Timeouts @@ -1013,6 +1023,15 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str: full original population.

+
+

Progress Bar Numbers

+

+ In terminal output, regex means how many accepted regexes have been processed. + cmp means planned regex-versus-string match operations across the selected + populations. It is not the number of TI entries. The count grows because many regexes are + tested against many strings, often across multiple regex types. +

+

Timeouts

@@ -1127,6 +1146,11 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str: background: #efe7d7; border: 1px solid var(--line); }} + .help {{ + cursor: help; + text-decoration: underline dotted; + text-underline-offset: 3px; + }} section {{ margin-top: 30px; }} @@ -1171,11 +1195,11 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str: Type - Accepted Regexes - Reference Union - Malicious TI - Observed - Benign Spillover + Accepted Regexes + Reference Union + Malicious TI + Observed + Benign Spillover From 2fed96e03f7acdc84dafb3e5752006f4086bc486 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 19:03:21 +0000 Subject: [PATCH 0045/1100] feat: add regex_store directory to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fab190b3b3..b157bbf2e3 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,4 @@ appendonly.aof /slipsOut/metadata/whitelist.conf /p2p_db.sqlite old-pipeline/ +databases/regex_store/ From 70d363fedbaaa99d04d379ed3c4ba33f30218772 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Wed, 18 Mar 2026 19:07:25 +0000 Subject: [PATCH 0046/1100] feat: add immune_web_sim runs directory to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b157bbf2e3..00db0b049e 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,4 @@ appendonly.aof /p2p_db.sqlite old-pipeline/ databases/regex_store/ +utils/immune_web_sim/runs/ From 80c9e245ca17dd081d91939cef598528769e350d Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:00:47 +0000 Subject: [PATCH 0047/1100] feat: add redis.conf to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 00db0b049e..673bdd91a1 100644 --- a/.gitignore +++ b/.gitignore @@ -180,3 +180,4 @@ appendonly.aof old-pipeline/ databases/regex_store/ utils/immune_web_sim/runs/ +config/redis.conf From 265aadfd6bee61f4d5d2cee16a0f957cddfd72bb Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:01:03 +0000 Subject: [PATCH 0048/1100] feat: add .vscode/launch.json to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 673bdd91a1..2132e765ed 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,4 @@ old-pipeline/ databases/regex_store/ utils/immune_web_sim/runs/ config/redis.conf +.vscode/launch.json From 6508482811d18d9e6a0d23389c81eb6465625781 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:01:34 +0000 Subject: [PATCH 0049/1100] feat: add .vscode/launch.json to .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2132e765ed..6553499eb7 100644 --- a/.gitignore +++ b/.gitignore @@ -181,4 +181,4 @@ old-pipeline/ databases/regex_store/ utils/immune_web_sim/runs/ config/redis.conf -.vscode/launch.json +.vscode/launch.json \ No newline at end of file From b99bd064b1a2383b326a49b5bce0b89e3561b828 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:02:13 +0000 Subject: [PATCH 0050/1100] feat: add tranco_top_benign_limit parameter to whitelists configuration --- config/slips.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/slips.yaml b/config/slips.yaml index f7df8e06e6..646a48f0b3 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -475,6 +475,10 @@ whitelists: # 2 weeks = 1209600 seconds online_whitelist_update_period: 86400 online_whitelist: https://tranco-list.eu/download/X5QNN/10000 + # Keep the first N Tranco domains in order in Redis under + # `tranco_top_domains` so other modules can reuse them as benign data. + # RegexGenerator and the offline regex coverage report use this limit. + tranco_top_benign_limit: 1000 # if this parameter is set to false, Slips runs with no whitelists at all. # May cause a lot of false positives From 1473f1527e6b2dd8cfda2da3ffae6562afddfc6a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:02:20 +0000 Subject: [PATCH 0051/1100] feat: add support for Tranco top benign domains in RegexGenerator configuration --- docs/regex_generator_module.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/regex_generator_module.md b/docs/regex_generator_module.md index ff5e507294..4a40d70e8d 100644 --- a/docs/regex_generator_module.md +++ b/docs/regex_generator_module.md @@ -55,6 +55,9 @@ regex_generator: store_rejected_regexes: false max_stored_rejected_regexes: 10000 seed_benign_samples: true + +whitelists: + tranco_top_benign_limit: 1000 ``` Configuration reference: @@ -92,6 +95,9 @@ Configuration reference: - `max_stored_rejected_regexes`: retention cap for rejected rows when `store_rejected_regexes` is enabled. Set `0` for unlimited retention. - `seed_benign_samples`: seed the benign DB once with a small built-in sample. +- `whitelists.tranco_top_benign_limit`: number of ordered Tranco domains kept + in Redis under `tranco_top_domains` and reused as benign data by + `RegexGenerator` and the offline coverage report. ## LLM request and response usage @@ -183,6 +189,21 @@ types: - `tls_sni` - `certificate_cn` +If the daily Tranco whitelist has already been downloaded by Slips, the module +also imports the ordered configured Tranco top benign domains from Redis into +the same domain-like benign corpus. + +Redis storage note: + +- Slips still stores the full downloaded Tranco whitelist in Redis under + `tranco_whitelisted_domains`. +- Slips now also stores a second Redis key, `tranco_top_domains`, as an + ordered list containing the configured top-ranked Tranco domains. +- `RegexGenerator` uses this ordered Redis list when it needs benign + high-reputation domains for domain-like regex testing. +- The number of domains kept in `tranco_top_domains` is configured with + `whitelists.tranco_top_benign_limit`. + It also builds one in-memory bloom filter per benign type and one bloom filter for generated regex hashes, but these do not replace the benign corpus scan. They help with exact membership checks and future scale improvements, while the @@ -263,6 +284,7 @@ inside the selected run output directory. The estimate is based on: - the RegexGenerator benign corpus DB, grouped by regex type +- the configured Tranco top benign domains from `whitelists.tranco_top_benign_limit` as extra benign data for domain-like types, when available in the Slips cache - TI-derived malicious reference strings from Redis and TI cache files, grouped by regex type - observed traffic strings from Zeek logs or `flows.sqlite`, grouped by regex type - the per-type reference union, which is `malicious TI ∪ observed traffic` From adc269c0041d34ce642c310a227f0a478458555c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:02:26 +0000 Subject: [PATCH 0052/1100] feat: add Redis storage details for Tranco top domains and configuration parameter --- docs/usage.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index d4cd642f4d..a7751f1f60 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -431,6 +431,17 @@ Slips download the top 10k domains from this list and by default and whitelists all evidence and alerts from and to these domains. Slips still shows the flows to and from these IoC. +Redis storage detail: + +- Slips still stores the full Tranco whitelist in Redis as the existing set + `tranco_whitelisted_domains`. +- Slips now also stores a second Redis key, `tranco_top_domains`, as an + ordered list containing the configured top-ranked Tranco domains. +- The new ordered key exists so other modules, such as `RegexGenerator`, can + reuse the actual top-ranked domains as benign data without losing ranking + order. +- The size of this ordered Redis list is controlled by + `whitelists.tranco_top_benign_limit` in `config/slips.yaml`. The tranco list is updated daily by default in Slips, but you can change how often to update it using the ```online_whitelist_update_period``` key in config/slips.yaml. From 2725c86a6d6def08e88b86d5b0cf5e0cc04771bb Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:02:35 +0000 Subject: [PATCH 0053/1100] feat: enhance RegexGenerator with Tranco top benign domains support and configuration --- modules/regex_generator/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/modules/regex_generator/README.md b/modules/regex_generator/README.md index f3ed14507b..b1b160ab99 100644 --- a/modules/regex_generator/README.md +++ b/modules/regex_generator/README.md @@ -43,6 +43,9 @@ regex_generator: store_rejected_regexes: false max_stored_rejected_regexes: 10000 seed_benign_samples: true + +whitelists: + tranco_top_benign_limit: 1000 ``` Configuration reference: @@ -80,6 +83,9 @@ Configuration reference: - `max_stored_rejected_regexes`: retention cap for rejected rows when `store_rejected_regexes` is enabled. Set `0` for unlimited retention. - `seed_benign_samples`: seed the benign DB once with a small built-in sample. +- `whitelists.tranco_top_benign_limit`: number of ordered Tranco domains kept + in Redis under `tranco_top_domains` and reused as benign data by + `RegexGenerator` and the offline coverage report. ## Runtime flow @@ -183,6 +189,21 @@ types: - `tls_sni` - `certificate_cn` +If the daily Tranco whitelist has already been downloaded by Slips, the module +also imports the ordered configured Tranco top benign domains from Redis into +the same domain-like benign corpus. + +Redis storage note: + +- The original Tranco whitelist behavior is still present: Slips stores the + full downloaded Tranco set in Redis under `tranco_whitelisted_domains`. +- Slips now additionally stores the configured top-ranked Tranco domains in + order under `tranco_top_domains`. +- `RegexGenerator` uses this new ordered Redis list so the top-ranked Tranco + domains can be treated as benign test data for domain-like regexes. +- The number of domains kept in `tranco_top_domains` is configured with + `whitelists.tranco_top_benign_limit`. + It builds one in-memory bloom filter per benign type and one additional bloom filter for generated regex hashes. These filters speed up exact membership checks, but they do not replace the benign corpus scan. Acceptance still @@ -253,6 +274,7 @@ inside the selected run output directory. The report estimates coverage against: - the local benign corpus DB, grouped by regex type +- the configured Tranco top benign domains from `whitelists.tranco_top_benign_limit` as extra benign data for domain-like types, when available in the Slips cache - TI-derived malicious reference strings from Redis and TI files, grouped by regex type - observed traffic strings from the same run, grouped by regex type and taken from Zeek logs or `flows.sqlite` - the per-type reference union, which is `malicious TI ∪ observed traffic` From 1c6672b7c265612aee630778490fe9c0b2fb727e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:02:43 +0000 Subject: [PATCH 0054/1100] feat: pass database instance to RegexGenerator initialization --- modules/regex_generator/regex_generator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/regex_generator/regex_generator.py b/modules/regex_generator/regex_generator.py index 56547be7d7..f9cbd0f9ad 100644 --- a/modules/regex_generator/regex_generator.py +++ b/modules/regex_generator/regex_generator.py @@ -178,6 +178,7 @@ def pre_main(self): self.conf, self.output_dir, self.ppid, + self.db, ) self.next_generation_at = time.time() self._log_detail("RegexGenerator module ready.") From fa022820765ab9b80d0a8737ec82dcf25ecf3917 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:03:07 +0000 Subject: [PATCH 0055/1100] feat: add tranco_top_benign_limit support and improve domain validation in UpdateManager --- modules/update_manager/update_manager.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/modules/update_manager/update_manager.py b/modules/update_manager/update_manager.py index ee777d0de8..a82bdf8158 100644 --- a/modules/update_manager/update_manager.py +++ b/modules/update_manager/update_manager.py @@ -135,6 +135,7 @@ def read_riskiq_creds(risk_iq_credentials_path): conf.online_whitelist_update_period() ) self.online_whitelist = conf.online_whitelist() + self.tranco_top_benign_limit = conf.tranco_top_benign_limit() self.enable_online_whitelist: bool = conf.enable_online_whitelist() self.enable_local_whitelist: bool = conf.enable_local_whitelist() @@ -1582,11 +1583,21 @@ def update_online_whitelist(self): response = self.responses["tranco_whitelist"] domains = [] for line in response.text.splitlines(): - domain = line.split(",")[1].strip() + parts = line.split(",") + if len(parts) < 2: + continue + domain = parts[1].strip().lower() + if not utils.is_valid_domain(domain): + continue domains.append(domain) self.db.store_tranco_whitelisted_domains( domains, ttl=self.online_whitelist_update_period ) + self.db.store_tranco_top_domains( + domains, + ttl=self.online_whitelist_update_period, + limit=self.tranco_top_benign_limit, + ) self.mark_feed_as_updated("tranco_whitelist") From 4d4ddb624cdffcea1badd54e363fe521d324f970 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:03:13 +0000 Subject: [PATCH 0056/1100] feat: add support for loading Tranco top benign populations and merging with existing data --- scripts/regex_coverage_report.py | 58 ++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/scripts/regex_coverage_report.py b/scripts/regex_coverage_report.py index 9c65681aeb..f91574f440 100644 --- a/scripts/regex_coverage_report.py +++ b/scripts/regex_coverage_report.py @@ -380,6 +380,39 @@ def load_benign_corpus(benign_db_path: Path) -> dict[str, set[str]]: return populations +def load_tranco_benign_populations( + ti_cache_port: int, + ti_cache_db: int, + limit: int, +) -> dict[str, set[str]]: + populations = {regex_type: set() for regex_type in REGEX_TYPES} + if redis is None: + return populations + + try: + cache_client = redis.Redis( + host="127.0.0.1", + port=ti_cache_port, + db=ti_cache_db, + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + if limit <= 0: + return populations + tranco_domains = cache_client.lrange("tranco_top_domains", 0, limit - 1) + except Exception: + return populations + + for domain in tranco_domains: + domain = normalize_domain(domain) + if not domain: + continue + for regex_type in DOMAIN_LIKE_TYPES: + add_string(populations, regex_type, domain) + return populations + + def parse_zeek_json_log(path: Path) -> Iterable[dict]: with path.open("r", encoding="utf-8") as handle: for line in handle: @@ -472,6 +505,14 @@ def load_observed_from_flows_sqlite( ) +def merge_populations( + base: dict[str, set[str]], extra: dict[str, set[str]] +) -> dict[str, set[str]]: + for regex_type, values in extra.items(): + base.setdefault(regex_type, set()).update(values) + return base + + def filename_from_uri(uri: str) -> str: normalized = normalize_uri(uri) if not normalized: @@ -1011,8 +1052,9 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str:

Benign Spillover

- Matches against the benign corpus. Lower is better. High benign spillover means the - regex set is too broad for that type. + Matches against the benign corpus. For domain-like types this benign side may also + include the Tranco top 1000 domains from the Slips cache. Lower is better. High benign + spillover means the regex set is too broad for that type.

@@ -1163,6 +1205,8 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str:

Offline estimate of accepted RegexGenerator coverage against three reference populations: benign corpus, TI-derived malicious strings, and observed traffic from the selected Slips run. + For domain-like types, the benign side also includes the Tranco top 1000 domains when available + in the Slips cache.

@@ -1283,6 +1327,8 @@ def main(): if args.full_scan: args.max_population_size = 0 args.sampling_ratio = 1.0 + config = ConfigParser() + tranco_top_benign_limit = config.tranco_top_benign_limit() ( run_output_dir, @@ -1294,6 +1340,14 @@ def main(): regexes_by_type = load_regexes(regex_db_path) benign_populations = load_benign_corpus(benign_db_path) + benign_populations = merge_populations( + benign_populations, + load_tranco_benign_populations( + args.ti_cache_port, + args.ti_cache_db, + tranco_top_benign_limit, + ), + ) observed_populations = load_observed_populations(run_output_dir) malicious_populations, ti_stats = load_ti_populations( args.redis_port, From 3b934132c707f3213eca758d1b23b170a21c1665 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:03:18 +0000 Subject: [PATCH 0057/1100] feat: implement tranco_top_benign_limit method for configurable limit --- slips_files/common/parsers/config_parser.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index 730dd47d4a..f923cefed9 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -145,6 +145,16 @@ def online_whitelist_update_period(self): update_period = 604800 return update_period + def tranco_top_benign_limit(self): + limit = self.read_configuration( + "whitelists", "tranco_top_benign_limit", 1000 + ) + try: + limit = int(limit) + except ValueError: + limit = 1000 + return max(0, limit) + def popup_alerts(self): return self.read_configuration("detection", "popup_alerts", False) From c8eb3fb474e78f124b9478497476bf28e972bae1 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:03:22 +0000 Subject: [PATCH 0058/1100] feat: add methods to store and retrieve Tranco top domains in DBManager --- slips_files/core/database/database_manager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/slips_files/core/database/database_manager.py b/slips_files/core/database/database_manager.py index ee892b3a64..0d9802e19c 100644 --- a/slips_files/core/database/database_manager.py +++ b/slips_files/core/database/database_manager.py @@ -363,6 +363,12 @@ def get_field_separator(self, *args, **kwargs): def store_tranco_whitelisted_domains(self, *args, **kwargs): return self.rdb.store_tranco_whitelisted_domains(*args, **kwargs) + def store_tranco_top_domains(self, *args, **kwargs): + return self.rdb.store_tranco_top_domains(*args, **kwargs) + + def get_tranco_top_domains(self, *args, **kwargs): + return self.rdb.get_tranco_top_domains(*args, **kwargs) + def is_whitelisted_tranco_domain(self, *args, **kwargs): return self.rdb.is_whitelisted_tranco_domain(*args, **kwargs) From 8985cd1d809d992ed81a860254c22d66dc7d4106 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:03:38 +0000 Subject: [PATCH 0059/1100] feat: add methods to store and retrieve Tranco top domains in RedisDB --- .../core/database/redis_db/constants.py | 1 + .../core/database/redis_db/database.py | 40 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/slips_files/core/database/redis_db/constants.py b/slips_files/core/database/redis_db/constants.py index 27348472c6..a798577a72 100644 --- a/slips_files/core/database/redis_db/constants.py +++ b/slips_files/core/database/redis_db/constants.py @@ -35,6 +35,7 @@ class Constants: MODIFIED_TIMEWINDOWS = "ModifiedTW" ACCUMULATED_THREAT_LEVELS = "accumulated_threat_levels" TRANCO_WHITELISTED_DOMAINS = "tranco_whitelisted_domains" + TRANCO_TOP_DOMAINS = "tranco_top_domains" WHITELIST = "whitelist" GROWING_ZEEK_DIR = "growing_zeek_dir" DHCP_SERVERS = "DHCP_servers" diff --git a/slips_files/core/database/redis_db/database.py b/slips_files/core/database/redis_db/database.py index d951ed43d1..9ead35f974 100644 --- a/slips_files/core/database/redis_db/database.py +++ b/slips_files/core/database/redis_db/database.py @@ -1150,6 +1150,41 @@ def store_tranco_whitelisted_domains( self.constants.TRANCO_WHITELISTED_DOMAINS, int(ttl) ) + def store_tranco_top_domains( + self, domains: List[str], ttl: Optional[int] = None, limit: int = 1000 + ): + """ + store the ordered top-ranked Tranco domains so ranking can be reused + later by modules that need the actual top N entries + """ + self.rcache.delete(self.constants.TRANCO_TOP_DOMAINS) + if limit <= 0: + return + + ordered_domains = [] + seen = set() + for domain in domains: + domain = str(domain or "").strip().lower() + if not domain or domain in seen: + continue + ordered_domains.append(domain) + seen.add(domain) + if len(ordered_domains) >= limit: + break + + if ordered_domains: + self.rcache.rpush( + self.constants.TRANCO_TOP_DOMAINS, *ordered_domains + ) + if ttl and ttl > 0: + self.rcache.expire( + self.constants.TRANCO_TOP_DOMAINS, int(ttl) + ) + + def get_tranco_top_domains(self, limit: Optional[int] = None): + end = -1 if limit is None or limit <= 0 else limit - 1 + return self.rcache.lrange(self.constants.TRANCO_TOP_DOMAINS, 0, end) or [] + def is_tranco_whitelist_expired(self) -> bool: """ checks if tranco whitelist is expired based on Redis TTL @@ -1164,7 +1199,10 @@ def is_whitelisted_tranco_domain(self, domain): ) def delete_tranco_whitelist(self): - return self.rcache.delete(self.constants.TRANCO_WHITELISTED_DOMAINS) + return self.rcache.delete( + self.constants.TRANCO_WHITELISTED_DOMAINS, + self.constants.TRANCO_TOP_DOMAINS, + ) def get_asn_info(self, ip: str) -> Optional[Dict[str, str]]: """ From 4345c77a2d214d33da297fdffe08a897afcd0a8e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:03:51 +0000 Subject: [PATCH 0060/1100] feat: add support for Tranco top benign limit and import domains into benign corpus --- .../database/sqlite_db/regex_generator_db.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/slips_files/core/database/sqlite_db/regex_generator_db.py b/slips_files/core/database/sqlite_db/regex_generator_db.py index 2819b41990..28769d4ca4 100644 --- a/slips_files/core/database/sqlite_db/regex_generator_db.py +++ b/slips_files/core/database/sqlite_db/regex_generator_db.py @@ -334,11 +334,13 @@ def __init__( conf, output_dir: str, main_pid: int, + db=None, ): self.logger = logger self.conf = conf self.output_dir = output_dir self.main_pid = main_pid + self.db = db self.store_dir = self._resolve_store_dir() self.store_rejected_regexes = self._read_store_rejected_regexes() self.max_stored_rejected_regexes = ( @@ -347,6 +349,7 @@ def __init__( self.seed_benign_samples = self._read_seed_benign_samples() self.enable_local_whitelist = self._read_enable_local_whitelist() self.local_whitelist_path = self._read_local_whitelist_path() + self.tranco_top_benign_limit = self._read_tranco_top_benign_limit() self.benign_db = BenignCorpusSQLiteDB( self.logger, str(Path(self.store_dir) / "benign_corpus.sqlite"), @@ -360,6 +363,7 @@ def __init__( if self.seed_benign_samples and self.benign_db.is_empty(): self.seed_default_benign_samples() self._import_local_whitelist_into_benign_corpus() + self._import_tranco_top_domains_into_benign_corpus() self.bloom_filters = self._build_bloom_filters() self.generated_regex_filter = self._build_generated_regex_filter() self.rejected_regex_filter = self._build_rejected_regex_filter() @@ -513,6 +517,27 @@ def _read_local_whitelist_path(self) -> str: return value.strip() return "config/whitelist.conf" + def _read_tranco_top_benign_limit(self) -> int: + value = self._read_int_config("tranco_top_benign_limit") + if value is not None: + return max(0, value) + + parser = ConfigParser() + parser_getter = getattr(parser, "tranco_top_benign_limit", None) + if callable(parser_getter): + try: + value = parser_getter() + except TypeError: + value = None + if isinstance(value, int): + return max(0, value) + if isinstance(value, str): + try: + return max(0, int(value.strip())) + except ValueError: + pass + return 1000 + def _read_string_config(self, method_name: str) -> str | None: getter = getattr(self.conf, method_name, None) if not callable(getter): @@ -582,6 +607,37 @@ def _import_local_whitelist_into_benign_corpus(self): source=f"local_whitelist:{whitelist_path}", ) + def _import_tranco_top_domains_into_benign_corpus(self): + if self.db is None or self.tranco_top_benign_limit <= 0: + return + + getter = getattr(self.db, "get_tranco_top_domains", None) + if not callable(getter): + return + + try: + domains = getter(limit=self.tranco_top_benign_limit) or [] + except TypeError: + domains = getter() or [] + + for domain in domains[: self.tranco_top_benign_limit]: + domain = str(domain or "").strip().lower() + if not utils.is_valid_domain(domain): + continue + + values = {domain} + hostname = utils.extract_hostname(domain) + if hostname: + values.add(hostname) + + for regex_type in WHITELIST_COMPATIBLE_REGEX_TYPES: + for value in values: + self.benign_db.insert_benign_string( + regex_type, + value, + source="tranco_top_1000", + ) + @staticmethod def _iter_whitelist_domains(whitelist_path: Path): with open(whitelist_path, encoding="utf-8") as whitelist: From 0d6d591dfbe25649b226acbd1b66be451e0344f1 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:03:59 +0000 Subject: [PATCH 0061/1100] feat: add mock for tranco_top_benign_limit in ModuleFactory tests --- tests/module_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/module_factory.py b/tests/module_factory.py index 8a14fb0e80..dfba92bf8f 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -82,6 +82,7 @@ def create_db_manager_obj( return_value=10000 ) conf.regex_generator_seed_benign_samples = Mock(return_value=True) + conf.tranco_top_benign_limit = Mock(return_value=1000) with ( # to prevent config/redis.conf from being overwritten @@ -211,6 +212,7 @@ def create_regex_generator_obj(self, mock_db, store_dir="dummy_output_dir/regex_ return_value=10000 ) conf.regex_generator_seed_benign_samples = Mock(return_value=True) + conf.tranco_top_benign_limit = Mock(return_value=1000) conf.rotation = Mock(return_value=True) conf.rotation_period = Mock(return_value="1day") From 06749cf9017dac0f562a82800f84cb965c29e8e3 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:04:06 +0000 Subject: [PATCH 0062/1100] feat: add tranco top benign limit configuration and import functionality in storage --- .../regex_generator/test_regex_generator.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/unit/modules/regex_generator/test_regex_generator.py b/tests/unit/modules/regex_generator/test_regex_generator.py index 6611d2319d..a222804513 100644 --- a/tests/unit/modules/regex_generator/test_regex_generator.py +++ b/tests/unit/modules/regex_generator/test_regex_generator.py @@ -25,6 +25,7 @@ def _build_storage_conf( max_stored_rejected_regexes: int = 10000, enable_local_whitelist: bool = True, local_whitelist_path: str = "config/whitelist.conf", + tranco_top_benign_limit: int = 1000, ): conf = Mock() conf.regex_generator_store_dir = Mock(return_value=store_dir) @@ -42,6 +43,7 @@ def _build_storage_conf( ) conf.enable_local_whitelist = Mock(return_value=enable_local_whitelist) conf.local_whitelist_path = Mock(return_value=local_whitelist_path) + conf.tranco_top_benign_limit = Mock(return_value=tranco_top_benign_limit) return conf @@ -64,6 +66,7 @@ def test_regex_generator_config_defaults(): assert parser.regex_generator_store_rejected_regexes() is False assert parser.regex_generator_max_stored_rejected_regexes() == 10000 assert parser.regex_generator_seed_benign_samples() is True + assert parser.tranco_top_benign_limit() == 1000 def test_regex_generator_config_sanitization(): @@ -115,6 +118,7 @@ def test_regex_generator_config_sanitization(): assert parser.regex_generator_store_rejected_regexes() is True assert parser.regex_generator_max_stored_rejected_regexes() == 10000 assert parser.regex_generator_seed_benign_samples() is False + assert parser.tranco_top_benign_limit() == 1000 def test_regex_generator_generation_interval_allows_zero(): @@ -571,6 +575,50 @@ def test_storage_skips_whitelist_import_when_disabled(tmp_path): storage.close() +def test_storage_imports_tranco_top_domains_into_matching_regex_types(tmp_path): + db = Mock() + db.get_tranco_top_domains = Mock( + return_value=["google.com", "github.com", "microsoft.com"] + ) + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf(str(tmp_path / "regex_generator")), + "dummy_output_dir", + 12345, + db=db, + ) + + assert "google.com" in storage.get_benign_examples("dns_domain", limit=200) + assert "github.com" in storage.get_benign_examples("tls_sni", limit=200) + assert "microsoft.com" in storage.get_benign_examples( + "certificate_cn", limit=200 + ) + db.get_tranco_top_domains.assert_called_once_with(limit=1000) + storage.close() + + +def test_storage_skips_tranco_import_when_limit_is_zero(tmp_path): + db = Mock() + db.get_tranco_top_domains = Mock(return_value=["tranco-only-example.test"]) + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf( + str(tmp_path / "regex_generator"), + seed_benign_samples=False, + tranco_top_benign_limit=0, + ), + "dummy_output_dir", + 12345, + db=db, + ) + + assert "tranco-only-example.test" not in storage.get_benign_examples( + "dns_domain", limit=200 + ) + db.get_tranco_top_domains.assert_not_called() + storage.close() + + def test_benign_corpus_scan_rejects_matching_regex(tmp_path): storage = RegexGeneratorStorage( Mock(), From e1674d3cd553f3c6dc22250ed7e2bfd7d1e3e828 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 09:04:12 +0000 Subject: [PATCH 0063/1100] feat: add test for updating online whitelist with top 1000 domains --- .../test_update_file_manager.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/modules/update_manager/test_update_file_manager.py b/tests/unit/modules/update_manager/test_update_file_manager.py index faa7bc84aa..cbc16e128a 100644 --- a/tests/unit/modules/update_manager/test_update_file_manager.py +++ b/tests/unit/modules/update_manager/test_update_file_manager.py @@ -288,6 +288,27 @@ def test_check_if_update_online_whitelist_not_updated(): update_manager.db.set_ti_feed_info.assert_not_called() +def test_update_online_whitelist_stores_top_1000_domains(): + update_manager = ModuleFactory().create_update_manager_obj() + update_manager.online_whitelist_update_period = 86400 + update_manager.tranco_top_benign_limit = 3 + lines = ["1,example.com", "2,google.com", "3,github.com"] + update_manager.responses["tranco_whitelist"] = Mock( + text="\n".join(lines) + ) + + update_manager.update_online_whitelist() + + update_manager.db.store_tranco_whitelisted_domains.assert_called_once_with( + ["example.com", "google.com", "github.com"], ttl=86400 + ) + update_manager.db.store_tranco_top_domains.assert_called_once_with( + ["example.com", "google.com", "github.com"], + ttl=86400, + limit=3, + ) + + @pytest.mark.parametrize( "headers, expected_last_modified", [ From b16c5eaf7290f14cc57076f776e7f9c907984c9a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:09:32 +0000 Subject: [PATCH 0064/1100] feat: add benign match strength threshold to regex generator configuration --- config/slips.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/slips.yaml b/config/slips.yaml index 646a48f0b3..4b447b38fc 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -292,6 +292,11 @@ regex_generator: # Set to 0 to disable the timeout. regex_validation_timeout_seconds: 2 + # Benign match strength threshold from 0 to 100. A generated regex is + # rejected only when its strongest match against a benign string reaches + # this score. Higher values are more permissive. + benign_match_strength_threshold: 75 + # Weighted random choice for the next regex type to generate. type_weights: dns_domain: 1 From e3161daec9e834c6b582227d09514b6b60b3ab94 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:09:39 +0000 Subject: [PATCH 0065/1100] feat: add benign match strength threshold for regex validation --- docs/regex_generator_module.md | 75 +++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/regex_generator_module.md b/docs/regex_generator_module.md index 4a40d70e8d..994c0231ce 100644 --- a/docs/regex_generator_module.md +++ b/docs/regex_generator_module.md @@ -44,6 +44,7 @@ regex_generator: recent_history_size: 0 max_regex_length: 180 regex_validation_timeout_seconds: 2 + benign_match_strength_threshold: 75 type_weights: dns_domain: 1 uri: 1 @@ -82,6 +83,9 @@ Configuration reference: - `regex_validation_timeout_seconds`: hard wall-clock timeout for local regex validation and benign-corpus matching. This prevents one pathological regex from freezing the module. Set `0` to disable it. +- `benign_match_strength_threshold`: score from `0` to `100` used during the + benign scan. A regex is rejected only if its strongest benign match reaches + or exceeds this threshold. Higher values are more permissive. - `type_weights`: weighted random choice among the supported regex types. - `store_dir`: directory containing `benign_corpus.sqlite` and `generated_regexes.sqlite`. Absolute paths are used as-is. Relative paths are @@ -122,6 +126,11 @@ The prompt requires the model to return exactly one regex line. No JSON, explanation, or code fences. The parser still accepts JSON-shaped replies as a fallback for compatibility, but the active prompt is raw-regex only. +After the reply arrives, the module does not reject on any benign hit. It +streams the benign corpus for the selected type, computes a benign +match-strength score for each regex/string match, and rejects only if some +benign string reaches or exceeds `benign_match_strength_threshold`. + V1 keeps one request in flight at a time, so response correlation is simple: only the matching `request_id` is accepted. If the local LLM is slow, the module keeps waiting and only logs a warning @@ -162,8 +171,9 @@ After the matching `llm_response` arrives, the module: 4. Applies static safety validation 5. Checks local duplicates with a bloom filter and exact SQLite lookup 6. Streams the benign corpus for the selected type -7. Rejects on the first benign match -8. Stores accepted regexes for later use +7. Computes a benign match-strength score for each regex/string match +8. Rejects only if some benign string reaches the configured threshold +9. Stores accepted regexes for later use Static validation rejects: @@ -176,6 +186,67 @@ Static validation rejects: - nested wildcard structures that risk catastrophic backtracking - invalid syntax +The benign match-strength score is an estimate from `0` to `100`. It is +computed per regex and per benign string using the strongest match span found +by Python `re.finditer()`. + +For one matched span, the score is: + +```text +score = + 40 * span_ratio + + 12 * start_bonus + + 12 * end_bonus + + 16 * full_bonus + + 30 * specificity_ratio + - 18 * wildcard_penalty +``` + +The result is clipped to `0..100`. The regex keeps the highest score it +obtains against that benign string. If any benign string reaches or exceeds +`benign_match_strength_threshold`, the regex is rejected. + +The terms mean: + +- `span_ratio = matched_span_length / benign_string_length` +- `start_bonus = 1` if the match starts at offset `0`, else `0` +- `end_bonus = 1` if the match ends at the final character, else `0` +- `full_bonus = 1` if the match covers the entire benign string, else `0` +- `specificity_ratio = literal_chars / (literal_chars + meta_tokens)` +- `wildcard_penalty = min(1.0, wildcard_points / ((literal_chars + meta_tokens) / 2))` + +Regex-specific features are measured from the regex text itself: + +- `literal_chars` counts explicit alphanumeric and common structural literal + characters such as `-`, `_`, `/`, `:`, `,`, `@`, and `=` +- escaped literals such as `\.` count as literal characters +- `meta_tokens` counts regex syntax such as `.`, `[]`, `*`, `+`, `?`, groups, + anchors, and generic escapes +- `wildcard_points` penalize broad constructs: + - `.*` or `.+` adds `2.5` + - bare `.` adds `1.5` + - `[` character classes add `1.2` + - `*`, `+`, and `?` add `1.0` + - generic escapes such as `\w` also add penalty + +Examples: + +- Regex `^google\.com$` against benign string `google.com` + - full span match, starts at `0`, ends at the end, full-match bonus applies + - specificity is high because most of the pattern is literal + - wildcard penalty is low + - score is very high, so this benign match is rejected + +- Regex `google` against benign string `google.com` + - only part of the string is covered + - it starts at `0` but does not end at the final character + - no full-match bonus + - score is lower and may stay below the threshold + +- Regex `.*com` + - may match a long suffix, but it is penalized heavily by the wildcard term + - this keeps broad permissive patterns from automatically looking “strong” + ## Benign corpus and bloom filters The module creates a benign corpus DB once and can seed it with a small sample From 84fde11c35c2e3cf9a410bc08a2e7b89751d83f8 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:09:44 +0000 Subject: [PATCH 0066/1100] feat: add benign match strength threshold configuration and scoring details --- modules/regex_generator/README.md | 76 +++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/modules/regex_generator/README.md b/modules/regex_generator/README.md index b1b160ab99..c5becb526e 100644 --- a/modules/regex_generator/README.md +++ b/modules/regex_generator/README.md @@ -32,6 +32,7 @@ regex_generator: recent_history_size: 0 max_regex_length: 180 regex_validation_timeout_seconds: 2 + benign_match_strength_threshold: 75 type_weights: dns_domain: 1 uri: 1 @@ -70,6 +71,9 @@ Configuration reference: - `regex_validation_timeout_seconds`: hard wall-clock timeout for local regex validation and benign-corpus matching. This prevents one pathological regex from freezing the module. Set `0` to disable it. +- `benign_match_strength_threshold`: score from `0` to `100` used during the + benign scan. A regex is rejected only if its strongest benign match reaches + or exceeds this threshold. Higher values are more permissive. - `type_weights`: weighted random choice among the five regex types. - `store_dir`: directory containing `benign_corpus.sqlite` and `generated_regexes.sqlite`. Absolute paths are used as-is. Relative paths are @@ -104,8 +108,11 @@ Each cycle does this: 7. Extract one regex line from the LLM reply. 8. Apply static safety validation. 9. Check local duplicate state with a bloom filter and exact DB lookup. -10. Stream the benign corpus for that type and stop on the first match. -11. Store accepted regexes in SQLite. Rejected regexes are only persisted if +10. Stream the benign corpus for that type and compute a benign match-strength + score for each regex/string match. +11. Reject the regex only if some benign string reaches or exceeds + `benign_match_strength_threshold`. +12. Store accepted regexes in SQLite. Rejected regexes are only persisted if `store_rejected_regexes` is enabled. V1 keeps only one LLM request in flight at a time. @@ -174,7 +181,70 @@ Static validation rejects: After static validation, the module first checks for exact duplicate regexes locally with a bloom filter and exact SQLite lookup, then scans the benign -corpus for the selected type and rejects the regex on the first benign match. +corpus for the selected type and computes a benign match-strength score for +every regex/string match. The regex is rejected only if any benign string +reaches or exceeds `benign_match_strength_threshold`. + +The current benign match-strength score is an estimate from `0` to `100`. It +is computed per regex and per benign string using the strongest match span +found by Python `re.finditer()`. + +For one matched span, the score is: + +```text +score = + 40 * span_ratio + + 12 * start_bonus + + 12 * end_bonus + + 16 * full_bonus + + 30 * specificity_ratio + - 18 * wildcard_penalty +``` + +The result is clipped to the range `0..100`. The regex keeps the highest score +it obtains against that benign string. If any benign string reaches or exceeds +`benign_match_strength_threshold`, the regex is rejected. + +The terms mean: + +- `span_ratio = matched_span_length / benign_string_length` +- `start_bonus = 1` if the match starts at offset `0`, else `0` +- `end_bonus = 1` if the match ends at the final character, else `0` +- `full_bonus = 1` if the match covers the entire benign string, else `0` +- `specificity_ratio = literal_chars / (literal_chars + meta_tokens)` +- `wildcard_penalty = min(1.0, wildcard_points / ((literal_chars + meta_tokens) / 2))` + +Regex-specific features are measured from the regex text itself: + +- `literal_chars` counts explicit alphanumeric and common structural literal + characters such as `-`, `_`, `/`, `:`, `,`, `@`, and `=` +- escaped literals such as `\.` count as literal characters +- `meta_tokens` counts regex syntax such as `.`, `[]`, `*`, `+`, `?`, groups, + anchors, and generic escapes +- `wildcard_points` penalize broad constructs: + - `.*` or `.+` adds `2.5` + - bare `.` adds `1.5` + - `[` character classes add `1.2` + - `*`, `+`, and `?` add `1.0` + - generic escapes such as `\w` also add penalty + +Examples: + +- Regex `^google\.com$` against benign string `google.com` + - full span match, starts at `0`, ends at the end, full match bonus applies + - specificity is high because most of the pattern is literal + - wildcard penalty is low + - score is very high, so this benign match is rejected + +- Regex `google` against benign string `google.com` + - only part of the string is covered + - it starts at `0` but does not end at the final character + - no full-match bonus + - score is lower and may stay below the threshold + +- Regex `.*com` + - may match a long suffix, but it is penalized heavily by the wildcard term + - this keeps broad permissive patterns from automatically looking “strong” ## Benign corpus and bloom filters From 471173850ca0b62fa1636636fc12fc46457dde96 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:09:49 +0000 Subject: [PATCH 0067/1100] feat: implement strong benign match scoring and threshold configuration --- modules/regex_generator/regex_generator.py | 103 +++++++++++++++++++-- 1 file changed, 97 insertions(+), 6 deletions(-) diff --git a/modules/regex_generator/regex_generator.py b/modules/regex_generator/regex_generator.py index f9cbd0f9ad..313072e0b1 100644 --- a/modules/regex_generator/regex_generator.py +++ b/modules/regex_generator/regex_generator.py @@ -131,6 +131,7 @@ def init(self): self.recent_history_size = 0 self.max_regex_length = 180 self.regex_validation_timeout_seconds = 2.0 + self.benign_match_strength_threshold = 75.0 self.type_weights = {regex_type: 1.0 for regex_type in REGEX_TYPES} self.pending_request = None self.next_generation_at = 0.0 @@ -163,6 +164,9 @@ def read_configuration(self): self.regex_validation_timeout_seconds = ( conf.regex_generator_regex_validation_timeout_seconds() ) + self.benign_match_strength_threshold = ( + conf.regex_generator_benign_match_strength_threshold() + ) self.type_weights = conf.regex_generator_type_weights() def pre_main(self): @@ -623,8 +627,9 @@ def _validate_and_store_regex(self, record: dict): try: with self._regex_validation_timeout(): compiled_regex = re.compile(record["regex"]) - matched_benign = self._find_matching_benign_value( + matched_benign, benign_match_score = self._find_strong_benign_match( record["regex_type"], + record["regex"], compiled_regex, ) except TimeoutError: @@ -634,8 +639,9 @@ def _validate_and_store_regex(self, record: dict): if matched_benign: self._store_rejected_regex( record, - "matched_benign_data", + "matched_benign_data_too_strong", matched_benign_value=matched_benign, + benign_match_score=benign_match_score, ) return @@ -653,6 +659,7 @@ def _store_rejected_regex( record: dict, rejection_reason: str, matched_benign_value: str | None = None, + benign_match_score: float | None = None, ): record["status"] = "rejected" record["rejection_reason"] = rejection_reason @@ -663,6 +670,8 @@ def _store_rejected_regex( if matched_benign_value else "" ) + if benign_match_score is not None: + extra += f" benign_match_score={benign_match_score:.2f}" self._log_detail( f"Rejected regex request_id={record['request_id']} " f"regex_type={record['regex_type']} reason={rejection_reason}" @@ -724,8 +733,90 @@ def _is_too_broad_alternation(regex: str) -> bool: return False return all(len(part) <= 2 for part in parts) - def _find_matching_benign_value(self, regex_type: str, compiled_regex) -> str | None: + def _find_strong_benign_match( + self, regex_type: str, regex_text: str, compiled_regex + ) -> tuple[str | None, float | None]: + regex_features = self._measure_regex_specificity(regex_text) for value in self.storage.iter_benign_strings(regex_type): - if compiled_regex.search(value): - return value - return None + score = self._compute_match_strength( + compiled_regex, value, regex_features + ) + if score >= self.benign_match_strength_threshold: + return value, score + return None, None + + def _compute_match_strength( + self, compiled_regex, value: str, regex_features: dict + ) -> float: + value = str(value or "") + if not value: + return 0.0 + + best_score = 0.0 + value_len = max(1, len(value)) + for match in compiled_regex.finditer(value): + start, end = match.span() + span_len = max(0, end - start) + if span_len <= 0: + continue + + span_ratio = min(1.0, span_len / value_len) + start_bonus = 1.0 if start == 0 else 0.0 + end_bonus = 1.0 if end == len(value) else 0.0 + full_bonus = 1.0 if start == 0 and end == len(value) else 0.0 + score = ( + 40.0 * span_ratio + + 12.0 * start_bonus + + 12.0 * end_bonus + + 16.0 * full_bonus + + 30.0 * regex_features["specificity_ratio"] + - 18.0 * regex_features["wildcard_penalty"] + ) + best_score = max(best_score, max(0.0, min(100.0, score))) + if best_score >= self.benign_match_strength_threshold: + break + return best_score + + @staticmethod + def _measure_regex_specificity(regex_text: str) -> dict: + literal_chars = 0 + meta_tokens = 0 + wildcard_points = 0.0 + idx = 0 + while idx < len(regex_text): + char = regex_text[idx] + next_char = regex_text[idx + 1] if idx + 1 < len(regex_text) else "" + + if char == "\\": + token = regex_text[idx : idx + 2] + meta_tokens += 1 + if len(token) == 2 and token[1] in ".^$*+?{}[]()|\\": + literal_chars += 1 + else: + wildcard_points += 1.0 + idx += 2 if next_char else 1 + continue + + if char.isalnum() or char in "-_/:,@=": + literal_chars += 1 + idx += 1 + continue + + meta_tokens += 1 + if char == "." and next_char in {"*", "+"}: + wildcard_points += 2.5 + elif char == ".": + wildcard_points += 1.5 + elif char == "[": + wildcard_points += 1.2 + elif char in {"*", "+", "?"}: + wildcard_points += 1.0 + idx += 1 + + effective_length = max(1, literal_chars + meta_tokens) + specificity_ratio = min(1.0, literal_chars / effective_length) + wildcard_penalty = min(1.0, wildcard_points / max(1.0, effective_length / 2)) + return { + "specificity_ratio": specificity_ratio, + "wildcard_penalty": wildcard_penalty, + } From 2bc09777d52b97e5dffe0e4aea3dca6f224c044f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:09:54 +0000 Subject: [PATCH 0068/1100] feat: add regex generator benign match strength threshold configuration --- slips_files/common/parsers/config_parser.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index f923cefed9..d49f7a0086 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -832,6 +832,16 @@ def regex_generator_regex_validation_timeout_seconds(self) -> float: value = 2.0 return max(0.0, value) + def regex_generator_benign_match_strength_threshold(self) -> float: + value = self.read_configuration( + "regex_generator", "benign_match_strength_threshold", 75 + ) + try: + value = float(value) + except (TypeError, ValueError): + value = 75.0 + return max(0.0, min(100.0, value)) + def regex_generator_type_weights(self) -> dict: default_weights = { "dns_domain": 1, From fa6df5df99e720c7c754c1baffbf9f873099690c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:10:00 +0000 Subject: [PATCH 0069/1100] feat: add benign match strength threshold configuration to regex generator --- tests/module_factory.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/module_factory.py b/tests/module_factory.py index dfba92bf8f..bfec09170d 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -196,6 +196,9 @@ def create_regex_generator_obj(self, mock_db, store_dir="dummy_output_dir/regex_ conf.regex_generator_regex_validation_timeout_seconds = Mock( return_value=2 ) + conf.regex_generator_benign_match_strength_threshold = Mock( + return_value=75 + ) conf.regex_generator_type_weights = Mock( return_value={ "dns_domain": 1, From 358d2f9ec238f4ce5639139ad2f2a891e1b26458 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:10:09 +0000 Subject: [PATCH 0070/1100] feat: enhance benign match strength validation and scoring in regex generator tests --- .../regex_generator/test_regex_generator.py | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/tests/unit/modules/regex_generator/test_regex_generator.py b/tests/unit/modules/regex_generator/test_regex_generator.py index a222804513..547961792b 100644 --- a/tests/unit/modules/regex_generator/test_regex_generator.py +++ b/tests/unit/modules/regex_generator/test_regex_generator.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2021 Sebastian Garcia # SPDX-License-Identifier: GPL-2.0-only import json +import re import time from unittest.mock import Mock @@ -61,6 +62,7 @@ def test_regex_generator_config_defaults(): assert parser.regex_generator_recent_history_size() == 0 assert parser.regex_generator_max_regex_length() == 180 assert parser.regex_generator_regex_validation_timeout_seconds() == 2 + assert parser.regex_generator_benign_match_strength_threshold() == 75 assert parser.regex_generator_store_dir() == "output/regex_generator" assert parser.regex_generator_persistent_store_dir() == "" assert parser.regex_generator_store_rejected_regexes() is False @@ -82,6 +84,7 @@ def test_regex_generator_config_sanitization(): "recent_history_size": -2, "max_regex_length": "bad", "regex_validation_timeout_seconds": "bad", + "benign_match_strength_threshold": "bad", "type_weights": { "dns_domain": 0, "uri": 0, @@ -106,6 +109,7 @@ def test_regex_generator_config_sanitization(): assert parser.regex_generator_recent_history_size() == 0 assert parser.regex_generator_max_regex_length() == 180 assert parser.regex_generator_regex_validation_timeout_seconds() == 2 + assert parser.regex_generator_benign_match_strength_threshold() == 75 assert parser.regex_generator_type_weights() == { "dns_domain": 1, "uri": 1, @@ -451,7 +455,7 @@ def test_validate_and_store_regex_rejects_validation_timeout(tmp_path): ) regex_generator.storage = Mock() regex_generator.storage.might_have_generated_regex.return_value = False - regex_generator._find_matching_benign_value = Mock( + regex_generator._find_strong_benign_match = Mock( side_effect=TimeoutError("timed out") ) @@ -653,7 +657,7 @@ def test_benign_corpus_scan_rejects_matching_regex(tmp_path): regex_type="dns_domain", status="rejected", ) - assert rejected[0]["rejection_reason"] == "matched_benign_data" + assert rejected[0]["rejection_reason"] == "matched_benign_data_too_strong" assert rejected[0]["matched_benign_value"] == "google.com" storage.close() @@ -698,11 +702,63 @@ def test_benign_corpus_scan_rejects_regex_matching_whitelist_domain(tmp_path): regex_type="dns_domain", status="rejected", ) - assert rejected[0]["rejection_reason"] == "matched_benign_data" + assert rejected[0]["rejection_reason"] == "matched_benign_data_too_strong" assert rejected[0]["matched_benign_value"] == "example.com" storage.close() +def test_partial_benign_match_can_be_accepted_below_strength_threshold(tmp_path): + storage = RegexGeneratorStorage( + Mock(), + _build_storage_conf(str(tmp_path / "regex_generator")), + "dummy_output_dir", + 12345, + ) + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.storage = storage + regex_generator.benign_match_strength_threshold = 80 + + regex_generator._validate_and_store_regex( + { + "regex_type": "dns_domain", + "regex": r"google", + "regex_hash": regex_generator._hash_regex(r"google"), + "backend_alias": "local_qwen", + "provider": "ollama", + "model": "qwen2.5:3b", + "temperature": 1.2, + "prompt_version": PROMPT_VERSION, + "request_id": "req-weak-benign", + "created_at": time.time(), + } + ) + + accepted = storage.get_generated_regexes(regex_type="dns_domain") + assert accepted + assert accepted[0]["regex"] == r"google" + storage.close() + + +def test_match_strength_scores_full_specific_match_higher_than_partial_match(tmp_path): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + full_score = regex_generator._compute_match_strength( + re.compile(r"^google\.com$"), + "google.com", + regex_generator._measure_regex_specificity(r"^google\.com$"), + ) + partial_score = regex_generator._compute_match_strength( + re.compile(r"google"), + "google.com", + regex_generator._measure_regex_specificity(r"google"), + ) + + assert full_score > partial_score + + def test_rejected_regexes_are_not_persisted_by_default(tmp_path): storage = RegexGeneratorStorage( Mock(), From 9e315607e7e053d8f74cae38e93cd9b350ab5a13 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:24:53 +0000 Subject: [PATCH 0071/1100] feat: enhance regex match strength reporting and scoring details in README --- modules/regex_generator/README.md | 38 ++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/modules/regex_generator/README.md b/modules/regex_generator/README.md index c5becb526e..81c0a4f495 100644 --- a/modules/regex_generator/README.md +++ b/modules/regex_generator/README.md @@ -277,7 +277,9 @@ Redis storage note: It builds one in-memory bloom filter per benign type and one additional bloom filter for generated regex hashes. These filters speed up exact membership checks, but they do not replace the benign corpus scan. Acceptance still -requires checking whether a regex matches any benign string. +requires computing the benign match-strength score against the benign corpus +and rejecting the regex only if some benign string reaches or exceeds +`benign_match_strength_threshold`. ## Stored regexes @@ -316,6 +318,40 @@ In that progress line: - `regex 247/781` means 247 accepted regexes have been evaluated out of 781 total accepted regexes. - `cmp 560,840/1,770,991` means regex-versus-string match operations, not raw TI entries. The number grows because many regexes are checked against many strings across the benign corpus, malicious TI, observed traffic, and reference-union populations. +The report reuses the same `0..100` match-strength function as the live +module, but it applies it to every regex/string comparison in the selected +populations: + +- if the regex does not match the string, the score is `0` +- if it matches, the score is computed with the same span/anchor/specificity/ + wildcard formula used by the generator + +For each regex and each population, the report now computes: + +- `match_count`: how many strings matched at all +- `avg_all ± std_all`: average and standard deviation over all tested strings, + with non-matches counted as `0` +- `avg_match ± std_match`: average and standard deviation over only the strings + that matched + +The top-regex table ranks regexes by: + +```text +strength_gap = malicious_avg_all - benign_avg_all +``` + +This favors regexes that score strongly and/or broadly on malicious strings +while staying weak on benign strings. + +The HTML report also includes a `Strength Scatter` plot per regex type: + +- X axis: benign `avg_all` +- Y axis: malicious `avg_all` +- ideal area: upper-left + +That plot is useful when there are too many regexes for a table alone to be +read comfortably. + If you want the exhaustive run for research, use: ```bash From b76a683c96125d1bc1c75e9aa6fd041f03fc1fa5 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:25:02 +0000 Subject: [PATCH 0072/1100] feat: refactor match strength computation and specificity measurement to use dedicated functions --- modules/regex_generator/regex_generator.py | 75 ++-------------------- 1 file changed, 6 insertions(+), 69 deletions(-) diff --git a/modules/regex_generator/regex_generator.py b/modules/regex_generator/regex_generator.py index 313072e0b1..6115666f33 100644 --- a/modules/regex_generator/regex_generator.py +++ b/modules/regex_generator/regex_generator.py @@ -12,6 +12,10 @@ from slips_files.common.abstracts.imodule import IModule from slips_files.common.parsers.config_parser import ConfigParser from slips_files.common.slips_utils import utils +from modules.regex_generator.match_strength import ( + compute_match_strength, + measure_regex_specificity, +) from slips_files.core.database.sqlite_db.regex_generator_db import ( REGEX_TYPES, RegexGeneratorStorage, @@ -748,75 +752,8 @@ def _find_strong_benign_match( def _compute_match_strength( self, compiled_regex, value: str, regex_features: dict ) -> float: - value = str(value or "") - if not value: - return 0.0 - - best_score = 0.0 - value_len = max(1, len(value)) - for match in compiled_regex.finditer(value): - start, end = match.span() - span_len = max(0, end - start) - if span_len <= 0: - continue - - span_ratio = min(1.0, span_len / value_len) - start_bonus = 1.0 if start == 0 else 0.0 - end_bonus = 1.0 if end == len(value) else 0.0 - full_bonus = 1.0 if start == 0 and end == len(value) else 0.0 - score = ( - 40.0 * span_ratio - + 12.0 * start_bonus - + 12.0 * end_bonus - + 16.0 * full_bonus - + 30.0 * regex_features["specificity_ratio"] - - 18.0 * regex_features["wildcard_penalty"] - ) - best_score = max(best_score, max(0.0, min(100.0, score))) - if best_score >= self.benign_match_strength_threshold: - break - return best_score + return compute_match_strength(compiled_regex, value, regex_features) @staticmethod def _measure_regex_specificity(regex_text: str) -> dict: - literal_chars = 0 - meta_tokens = 0 - wildcard_points = 0.0 - idx = 0 - while idx < len(regex_text): - char = regex_text[idx] - next_char = regex_text[idx + 1] if idx + 1 < len(regex_text) else "" - - if char == "\\": - token = regex_text[idx : idx + 2] - meta_tokens += 1 - if len(token) == 2 and token[1] in ".^$*+?{}[]()|\\": - literal_chars += 1 - else: - wildcard_points += 1.0 - idx += 2 if next_char else 1 - continue - - if char.isalnum() or char in "-_/:,@=": - literal_chars += 1 - idx += 1 - continue - - meta_tokens += 1 - if char == "." and next_char in {"*", "+"}: - wildcard_points += 2.5 - elif char == ".": - wildcard_points += 1.5 - elif char == "[": - wildcard_points += 1.2 - elif char in {"*", "+", "?"}: - wildcard_points += 1.0 - idx += 1 - - effective_length = max(1, literal_chars + meta_tokens) - specificity_ratio = min(1.0, literal_chars / effective_length) - wildcard_penalty = min(1.0, wildcard_points / max(1.0, effective_length / 2)) - return { - "specificity_ratio": specificity_ratio, - "wildcard_penalty": wildcard_penalty, - } + return measure_regex_specificity(regex_text) From 863a382c2c00e3504423617ae6a218280019dc87 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:25:21 +0000 Subject: [PATCH 0073/1100] feat: enhance benign regex acceptance criteria with match-strength scoring and reporting --- docs/regex_generator_module.md | 42 +++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/regex_generator_module.md b/docs/regex_generator_module.md index 994c0231ce..92129667b7 100644 --- a/docs/regex_generator_module.md +++ b/docs/regex_generator_module.md @@ -278,8 +278,9 @@ Redis storage note: It also builds one in-memory bloom filter per benign type and one bloom filter for generated regex hashes, but these do not replace the benign corpus scan. They help with exact membership checks and future scale improvements, while the -acceptance decision still requires testing whether the regex matches any benign -string. +acceptance decision still requires computing the benign match-strength score +against the benign corpus and rejecting the regex only if some benign string +reaches or exceeds `benign_match_strength_threshold`. The current benign acceptance gate is: @@ -287,7 +288,9 @@ The current benign acceptance gate is: SELECT value FROM benign_strings WHERE regex_type = ? ``` -streamed line by line until the first match. +streamed line by line while the module computes the benign match-strength score +for each string. The regex is rejected only if a score reaches the configured +threshold. ## Reading accepted regexes from other modules @@ -327,6 +330,39 @@ In that progress line: - `regex 247/781` means 247 accepted regexes have been evaluated out of 781 total accepted regexes. - `cmp 560,840/1,770,991` means regex-versus-string match operations, not raw TI entries. The number grows because many regexes are checked against many strings across the benign corpus, malicious TI, observed traffic, and reference-union populations. +The report reuses the same `0..100` match-strength function as the live +generator, but it applies it to every regex/string comparison in the selected +populations: + +- non-match: score `0` +- match: the same span/anchor/specificity/wildcard formula used by + `RegexGenerator` + +For each regex and each population, the report computes: + +- `match_count`: number of strings matched at all +- `avg_all ± std_all`: average and standard deviation over all tested strings, + with non-matches counted as `0` +- `avg_match ± std_match`: average and standard deviation over only the strings + that matched + +The top-regex ranking uses: + +```text +strength_gap = malicious_avg_all - benign_avg_all +``` + +So the “best” regexes in the report are the ones that are stronger and/or +broader on malicious strings while staying weak on benign strings. + +The HTML output also adds a `Strength Scatter` plot per regex type: + +- X axis: benign `avg_all` +- Y axis: malicious `avg_all` +- ideal area: upper-left + +This gives a faster view of many regexes than a table alone. + If you want the exhaustive run for research, use: ```bash From cc5c25a16c46cea7e4df1b82d53d60a88340f59b Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:25:41 +0000 Subject: [PATCH 0074/1100] feat: add statistical analysis for regex match strength and coverage reporting --- scripts/regex_coverage_report.py | 205 ++++++++++++++++++++++++++++--- 1 file changed, 189 insertions(+), 16 deletions(-) diff --git a/scripts/regex_coverage_report.py b/scripts/regex_coverage_report.py index f91574f440..f2042efb2c 100644 --- a/scripts/regex_coverage_report.py +++ b/scripts/regex_coverage_report.py @@ -19,6 +19,7 @@ import argparse import json +import math import random import re import signal @@ -45,6 +46,10 @@ from slips_files.common.parsers.config_parser import ConfigParser from slips_files.common.slips_utils import utils from slips_files.core.database.sqlite_db.regex_generator_db import REGEX_TYPES +from modules.regex_generator.match_strength import ( + compute_match_strength, + measure_regex_specificity, +) DOMAIN_LIKE_TYPES = ("dns_domain", "tls_sni", "certificate_cn") @@ -736,6 +741,40 @@ def sample_population( return sampled, original_total +def mean_score(scores: list[float]) -> float | None: + if not scores: + return None + return sum(scores) / len(scores) + + +def stddev_score(scores: list[float]) -> float | None: + if not scores: + return None + avg = mean_score(scores) + if avg is None: + return None + variance = sum((score - avg) ** 2 for score in scores) / len(scores) + return math.sqrt(variance) + + +def build_score_stats( + scores_all: list[float], + matched_scores: list[float], + total_values: int, +) -> dict: + match_count = len(matched_scores) + return { + "total_evaluated": total_values, + "match_count": match_count, + "match_ratio": (match_count / total_values) if total_values else None, + "avg_all": mean_score(scores_all), + "std_all": stddev_score(scores_all), + "avg_match": mean_score(matched_scores), + "std_match": stddev_score(matched_scores), + "max": max(scores_all) if scores_all else None, + } + + def compute_coverage( compiled_regexes: dict[str, list[dict]], benign_populations: dict[str, set[str]], @@ -804,23 +843,44 @@ def compute_coverage( "regex": row["regex"], "request_id": row["request_id"], "matches": {}, + "score_stats": {}, "timed_out_populations": [], "unique_reference_matches": 0, "score": 0, + "quality_score": 0.0, + "strength_gap": 0.0, } compiled = row["compiled"] + regex_features = measure_regex_specificity(row["regex"]) comparisons_for_regex = sum(len(values) for values in population_map.values()) for population_name, values in population_map.items(): try: with timeout_context(match_timeout_seconds): - matched = [ - value for value in values if compiled.search(value) - ] + matched = [] + scores_all = [] + matched_scores = [] + for value in values: + score = compute_match_strength( + compiled, + value, + regex_features, + ) + scores_all.append(score) + if score > 0: + matched.append(value) + matched_scores.append(score) except TimeoutError: matched = [] + scores_all = [] + matched_scores = [] detail["timed_out_populations"].append(population_name) population_timeout_counts[population_name] += 1 detail["matches"][population_name] = matched + detail["score_stats"][population_name] = build_score_stats( + scores_all, + matched_scores, + len(values), + ) overall_matches[population_name].update(matched) detail["unique_reference_matches"] = len( @@ -830,6 +890,10 @@ def compute_coverage( len(detail["matches"]["reference_union"]) - len(detail["matches"]["benign"]) ) + malicious_avg = detail["score_stats"]["malicious"]["avg_all"] or 0.0 + benign_avg = detail["score_stats"]["benign"]["avg_all"] or 0.0 + detail["strength_gap"] = malicious_avg - benign_avg + detail["quality_score"] = detail["strength_gap"] regex_details.append(detail) if progress is not None: progress.advance( @@ -840,7 +904,8 @@ def compute_coverage( regex_details.sort( key=lambda item: ( - item["score"], + item["quality_score"], + item["score_stats"]["malicious"]["avg_all"] or 0.0, item["unique_reference_matches"], -len(item["matches"]["benign"]), ), @@ -925,6 +990,89 @@ def ratio_text(value: float | None) -> str: return f"{formatted}%" +def score_text(value: float | None) -> str: + if value is None: + return "n/a" + return f"{value:.2f}" + + +def avg_std_text(stats: dict) -> str: + avg = stats.get("avg_all") + std = stats.get("std_all") + if avg is None: + return "n/a" + if std is None: + return f"{avg:.2f}" + return f"{avg:.2f} ± {std:.2f}" + + +def matched_avg_std_text(stats: dict) -> str: + avg = stats.get("avg_match") + std = stats.get("std_match") + if avg is None: + return "n/a" + if std is None: + return f"{avg:.2f}" + return f"{avg:.2f} ± {std:.2f}" + + +def render_scatter_plot(regex_type: str, regex_rows: list[dict]) -> str: + points = [] + width = 520 + height = 360 + padding = 44 + inner_w = width - padding * 2 + inner_h = height - padding * 2 + usable_rows = 0 + for row in regex_rows: + benign_avg = row["score_stats"]["benign"]["avg_all"] + malicious_avg = row["score_stats"]["malicious"]["avg_all"] + if benign_avg is None and malicious_avg is None: + continue + usable_rows += 1 + x = padding + (benign_avg or 0.0) / 100.0 * inner_w + y = height - padding - (malicious_avg or 0.0) / 100.0 * inner_h + quality = row.get("quality_score", 0.0) + color = "#1e7a46" if quality >= 0 else "#a73f24" + radius = 3 if row["score_stats"]["malicious"]["match_count"] < 5 else 4 + title = ( + f"{row['regex']}\n" + f"malicious avg_all={score_text(malicious_avg)} std_all={score_text(row['score_stats']['malicious']['std_all'])} " + f"avg_match={score_text(row['score_stats']['malicious']['avg_match'])} matches={row['score_stats']['malicious']['match_count']}\n" + f"benign avg_all={score_text(benign_avg)} std_all={score_text(row['score_stats']['benign']['std_all'])} " + f"avg_match={score_text(row['score_stats']['benign']['avg_match'])} matches={row['score_stats']['benign']['match_count']}\n" + f"gap={score_text(row.get('strength_gap'))}" + ) + points.append( + f'' + f"{escape(title)}" + ) + + if usable_rows == 0: + return '

No benign/malicious score data available for this type.

' + + return f""" +
+

Strength Scatter

+

Each point is one accepted regex. X uses the benign average score across all tested benign strings, with non-matches counted as 0. Y uses the malicious average score across all tested malicious strings, with non-matches counted as 0. The ideal area is upper-left.

+ + + + + + + Benign average score + Malicious average score + 0 + 100 + 0 + 100 + {''.join(points)} + +
+ """ + + def render_html(report: dict, sample_limit: int, top_regexes: int) -> str: rows = [] for regex_type in REGEX_TYPES: @@ -948,6 +1096,7 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str: details = report["types"][regex_type] populations = details["populations"] regex_rows = details["regex_details"][:top_regexes] + all_regex_rows = details["regex_details"] population_blocks = [] for population_name in ("reference_union", "malicious", "observed", "benign"): @@ -978,11 +1127,14 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str: f""" {escape(row['regex'])} + {row['score_stats']['malicious']['match_count']} + {avg_std_text(row['score_stats']['malicious'])} + {matched_avg_std_text(row['score_stats']['malicious'])} + {row['score_stats']['benign']['match_count']} + {avg_std_text(row['score_stats']['benign'])} + {matched_avg_std_text(row['score_stats']['benign'])} + {score_text(row.get('strength_gap'))} {len(row['matches']['reference_union'])} - {len(row['matches']['malicious'])} - {len(row['matches']['observed'])} - {len(row['matches']['benign'])} - {row['score']} {len(row['timed_out_populations'])} """ @@ -995,21 +1147,25 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str:
{''.join(population_blocks)}
-

Top Regexes

+ {render_scatter_plot(regex_type, all_regex_rows)} +

Top Regexes By Malicious-vs-Benign Strength

+ + + + + + + - - - - - {''.join(regex_table_rows) or ''} + {''.join(regex_table_rows) or ''}
RegexMalicious MatchesMalicious All Avg ± StdMalicious Matched Avg ± StdBenign MatchesBenign All Avg ± StdBenign Matched Avg ± StdStrength Gap Reference UnionMalicious TIObservedBenignScore Timeouts
No accepted regexes.
No accepted regexes.
@@ -1084,8 +1240,25 @@ def render_html(report: dict, sample_limit: int, top_regexes: int) -> str:

Top Regexes Score

- The score is currently reference_union_matches - benign_matches. It is a rough - usefulness ranking, not a formal quality metric. + The top-regex ranking now uses strength_gap = malicious_avg_all - benign_avg_all. + Both averages are computed over all tested strings in that population, with non-matches counted as 0. + Higher is better because it means broader and/or stronger malicious matches with weaker benign matches. +

+
+
+

Match Strength

+

+ Each regex/string match gets a score from 0 to 100 using the same + formula as the live RegexGenerator benign filter. The score rewards wider coverage, anchoring, + and specificity, and penalizes broad wildcard-heavy patterns. In the report, non-matches are + treated as score 0 when computing whole-population averages and standard deviations. +

+
+
+

Strength Scatter

+

+ Each point is one regex. X is the average benign match score. Y is the average malicious match score. + The ideal region is upper-left: high malicious strength and low benign strength.

From 61d9321c06fda5c8d645ef9f7258fa543cc90141 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:45:49 +0000 Subject: [PATCH 0075/1100] feat: enhance runtime behavior for benign string imports based on alert evidence --- docs/regex_generator_module.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/regex_generator_module.md b/docs/regex_generator_module.md index 92129667b7..4b81a2290f 100644 --- a/docs/regex_generator_module.md +++ b/docs/regex_generator_module.md @@ -264,6 +264,27 @@ If the daily Tranco whitelist has already been downloaded by Slips, the module also imports the ordered configured Tranco top benign domains from Redis into the same domain-like benign corpus. +During runtime, the module also listens for `tw_closed`. When a finished time +window belongs to one of the host IPs of the machine running Slips, it checks +that host TW for alerts and evidence: + +- if the host TW has any alert or any evidence, it imports nothing from that TW +- if the host TW has zero alerts and zero evidence, it imports additional + benign strings from that clean local TW + +The runtime benign import currently uses: + +- DNS query names -> `dns_domain` +- HTTP hostnames -> `dns_domain` +- TLS `server_name` -> `tls_sni` +- certificate `subject` CN -> `certificate_cn` +- filenames derived from HTTP URIs -> `filename` + +The module logs the total alert count, total evidence count, and a separate +best-effort anomaly-evidence count for that finished host TW. The anomaly +count is informative only; the actual import gate is strict zero alerts and +zero evidence. + Redis storage note: - Slips still stores the full downloaded Tranco whitelist in Redis under From 17194c140d04e63b7a6307d47f39529ec4c0dd74 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:45:56 +0000 Subject: [PATCH 0076/1100] feat: enhance runtime behavior for benign string imports based on host alert checks --- modules/regex_generator/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/modules/regex_generator/README.md b/modules/regex_generator/README.md index 81c0a4f495..55136e3c7d 100644 --- a/modules/regex_generator/README.md +++ b/modules/regex_generator/README.md @@ -263,6 +263,27 @@ If the daily Tranco whitelist has already been downloaded by Slips, the module also imports the ordered configured Tranco top benign domains from Redis into the same domain-like benign corpus. +During runtime, the module also listens for `tw_closed`. When a finished time +window belongs to one of the host IPs of the machine running Slips, it checks +that host TW for alerts and evidence: + +- if the host TW has any alert or any evidence, nothing is imported +- if the host TW has zero alerts and zero evidence, the module learns extra + benign strings from that clean local TW + +The runtime benign import currently uses: + +- DNS query names -> `dns_domain` +- HTTP hostnames -> `dns_domain` +- TLS `server_name` -> `tls_sni` +- certificate `subject` CN -> `certificate_cn` +- filenames derived from HTTP URIs -> `filename` + +The module logs the total alert count, total evidence count, and a separate +best-effort anomaly-evidence count for that finished host TW. The anomaly +count is only for visibility; the import gate itself is strict: the TW must +have zero alerts and zero evidence. + Redis storage note: - The original Tranco whitelist behavior is still present: Slips stores the From 86d706e6cc29034e0e8b5ce6462c3bf555c7f583 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:46:05 +0000 Subject: [PATCH 0077/1100] feat: add handling for 'tw_closed' messages to import benign strings --- modules/regex_generator/regex_generator.py | 147 +++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/modules/regex_generator/regex_generator.py b/modules/regex_generator/regex_generator.py index 6115666f33..31e68de9d2 100644 --- a/modules/regex_generator/regex_generator.py +++ b/modules/regex_generator/regex_generator.py @@ -8,6 +8,7 @@ import time import uuid from hashlib import sha256 +from urllib.parse import urlparse from slips_files.common.abstracts.imodule import IModule from slips_files.common.parsers.config_parser import ConfigParser @@ -117,8 +118,10 @@ class RegexGenerator(IModule): def init(self): self.c_llm = self.db.subscribe(self.db.channels.LLM_RESPONSE) + self.c_tw_closed = self.db.subscribe("tw_closed") self.channels = { self.db.channels.LLM_RESPONSE: self.c_llm, + "tw_closed": self.c_tw_closed, } self.storage = None self.enabled = False @@ -207,6 +210,8 @@ def shutdown_gracefully(self): return True def main(self): + self._handle_one_tw_closed_message() + now = time.time() if self.pending_request: self._handle_pending_response(now) @@ -237,6 +242,148 @@ def main(self): ) self._send_generation_request(regex_type, backend) + def _handle_one_tw_closed_message(self): + if self.storage is None: + return + + msg = self.get_msg("tw_closed") + if not msg: + return + + profileid, twid = self._split_profileid_twid(msg.get("data", "")) + if not profileid or not twid: + return + + if not self._is_host_profile(profileid): + return + + alerts = self.db.get_profileid_twid_alerts(profileid, twid) or {} + evidence = self._normalize_evidence_records( + self.db.get_twid_evidence(profileid, twid) or {} + ) + anomaly_evidence_count = self._count_anomaly_evidence(evidence) + self._log_detail( + f"Finished host TW profileid={profileid} twid={twid} " + f"alerts={len(alerts)} evidence={len(evidence)} " + f"anomaly_evidence={anomaly_evidence_count}" + ) + + if alerts or evidence: + return + + learned = self._extract_benign_candidates_from_twid(profileid, twid) + learned_counts = {} + source = f"clean_client_tw:{profileid}:{twid}" + for regex_type, values in learned.items(): + inserted = self.storage.add_benign_strings(regex_type, values, source) + if inserted: + learned_counts[regex_type] = inserted + + if learned_counts: + summary = ", ".join( + f"{regex_type}={count}" + for regex_type, count in sorted(learned_counts.items()) + ) + self._log_detail( + f"Imported runtime benign strings from clean host TW " + f"profileid={profileid} twid={twid}: {summary}" + ) + + @staticmethod + def _split_profileid_twid(profileid_twid: str) -> tuple[str, str]: + text = str(profileid_twid or "").strip() + if not text or "_" not in text: + return "", "" + profileid, twid = text.rsplit("_", 1) + return profileid, twid + + def _is_host_profile(self, profileid: str) -> bool: + profile_ip = str(profileid or "").split("_", 1)[-1] + host_ips = {str(ip).strip() for ip in self.db.get_all_host_ips() or []} + return profile_ip in host_ips + + @staticmethod + def _normalize_evidence_records(raw_evidence: dict) -> dict[str, dict]: + normalized = {} + for evidence_id, payload in (raw_evidence or {}).items(): + if isinstance(payload, str): + try: + payload = json.loads(payload) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + normalized[evidence_id] = payload + return normalized + + @staticmethod + def _count_anomaly_evidence(evidence_records: dict[str, dict]) -> int: + anomaly_evidence_types = {"MALICIOUS_FLOW"} + count = 0 + for evidence in evidence_records.values(): + evidence_type = str(evidence.get("evidence_type", "")) + description = str(evidence.get("description", "")).lower() + if ( + evidence_type in anomaly_evidence_types + or "anomaly" in evidence_type.lower() + or "anomaly" in description + ): + count += 1 + return count + + def _extract_benign_candidates_from_twid( + self, profileid: str, twid: str + ) -> dict[str, set[str]]: + learned = {regex_type: set() for regex_type in REGEX_TYPES} + altflows = self.db.get_all_altflows_in_profileid_twid(profileid, twid) or [] + for row in altflows: + flow = row.get("flow", {}) + flow_type = row.get("flow_type") or flow.get("type_") + if flow_type == "dns": + domain = self._normalize_domain(flow.get("query", "")) + if domain: + learned["dns_domain"].add(domain) + elif flow_type == "http": + host = self._normalize_domain(flow.get("host", "")) + if host: + learned["dns_domain"].add(host) + filename = self._extract_filename_from_uri(flow.get("uri", "")) + if filename: + learned["filename"].add(filename) + elif flow_type == "ssl": + server_name = self._normalize_domain(flow.get("server_name", "")) + if server_name: + learned["tls_sni"].add(server_name) + cn = self._extract_cn(flow.get("subject", "")) + if cn: + learned["certificate_cn"].add(cn) + return learned + + @staticmethod + def _normalize_domain(value: str) -> str: + domain = str(value or "").strip().rstrip(".").lower() + if not domain or not utils.is_valid_domain(domain): + return "" + return domain + + @staticmethod + def _extract_cn(subject: str) -> str: + match = re.search(r"(?:^|,)CN=([^,]+)", str(subject or "")) + if not match: + return "" + return match.group(1).strip() + + @staticmethod + def _extract_filename_from_uri(uri: str) -> str: + value = str(uri or "").strip() + if not value: + return "" + parsed = urlparse(value) + path = parsed.path or value + filename = path.rsplit("/", 1)[-1].strip() + if not filename or "." not in filename: + return "" + return filename + def _init_log_file(self): if not self.create_log_file: return From 3c576393ded5e92cf8fc91a941ddbbec86b7bdcc Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:46:15 +0000 Subject: [PATCH 0078/1100] feat: add method to retrieve alternative flows by profile ID and TWID --- slips_files/core/database/database_manager.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/slips_files/core/database/database_manager.py b/slips_files/core/database/database_manager.py index 0d9802e19c..fe4a85eca8 100644 --- a/slips_files/core/database/database_manager.py +++ b/slips_files/core/database/database_manager.py @@ -869,6 +869,11 @@ def get_altflow_from_uid(self, *args, **kwargs): def get_all_flows_in_profileid_twid(self, *args, **kwargs): return self.sqlite.get_all_flows_in_profileid_twid(*args, **kwargs) + def get_all_altflows_in_profileid_twid(self, *args, **kwargs): + return self.sqlite.get_all_altflows_in_profileid_twid( + *args, **kwargs + ) + def get_all_flows_in_profileid(self, *args, **kwargs): return self.sqlite.get_all_flows_in_profileid(*args, **kwargs) From 5e33a41e1984a56d3e71896c451c92554a956511 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:46:23 +0000 Subject: [PATCH 0079/1100] feat: add method to insert multiple benign strings and return count of inserted entries --- .../database/sqlite_db/regex_generator_db.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/slips_files/core/database/sqlite_db/regex_generator_db.py b/slips_files/core/database/sqlite_db/regex_generator_db.py index 28769d4ca4..b3611aafab 100644 --- a/slips_files/core/database/sqlite_db/regex_generator_db.py +++ b/slips_files/core/database/sqlite_db/regex_generator_db.py @@ -111,15 +111,16 @@ def insert_benign_string( value: str, source: str, created_at: float | None = None, - ): + ) -> bool: created_at = created_at or time() value_hash = _make_sha256(f"{regex_type}\0{value}") - self.execute( + cursor = self.execute( "INSERT OR IGNORE INTO benign_strings " "(regex_type, value, value_hash, source, created_at) " "VALUES (?, ?, ?, ?, ?)", (regex_type, value, value_hash, source, created_at), ) + return bool(cursor and cursor.rowcount) def seed_strings(self, seed_samples: Dict[str, Iterable[str]], source: str): for regex_type, values in seed_samples.items(): @@ -695,6 +696,29 @@ def get_benign_examples(self, regex_type: str, limit: int = 5) -> List[str]: def iter_benign_strings(self, regex_type: str): yield from self.benign_db.iter_values(regex_type) + def add_benign_strings( + self, + regex_type: str, + values: Iterable[str], + source: str, + ) -> int: + inserted = 0 + bloom = self.bloom_filters.get(regex_type) + for value in values: + normalized = str(value or "").strip() + if not normalized: + continue + added = self.benign_db.insert_benign_string( + regex_type, + normalized, + source=source, + ) + if added: + inserted += 1 + if bloom is not None: + bloom.add(normalized) + return inserted + def get_recent_history(self, regex_type: str, limit: int) -> List[dict]: return self.generated_db.get_recent_history(regex_type, limit) From 006b33165db2bf777a3c52a62b0eaec9b16678e5 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:46:31 +0000 Subject: [PATCH 0080/1100] feat: add method to retrieve all alternative flows by profile ID and TWID --- .../core/database/sqlite_db/database.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/slips_files/core/database/sqlite_db/database.py b/slips_files/core/database/sqlite_db/database.py index 3638acd8cd..9ed0626e90 100644 --- a/slips_files/core/database/sqlite_db/database.py +++ b/slips_files/core/database/sqlite_db/database.py @@ -3,7 +3,6 @@ from datetime import datetime from typing import List, Dict import os.path -import sqlite3 import json import csv from dataclasses import asdict @@ -105,6 +104,26 @@ def get_all_flows_in_profileid_twid(self, profileid, twid): res[uid] = json.loads(flow) return res + def get_all_altflows_in_profileid_twid(self, profileid, twid): + condition = f'profileid = "{profileid}" ' f'AND twid = "{twid}"' + altflows: list = self.select("altflows", condition=condition) + if not altflows: + return [] + + rows = [] + for altflow in altflows: + rows.append( + { + "uid": altflow[0], + "flow": json.loads(altflow[1]), + "label": altflow[2], + "profileid": altflow[3], + "twid": altflow[4], + "flow_type": altflow[5], + } + ) + return rows + def get_all_flows_in_profileid(self, profileid) -> Dict[str, dict]: """ Return a list of all the flows in this profileid From 32c332486021d45cdbaf32e32f6c198575e04c27 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:46:38 +0000 Subject: [PATCH 0081/1100] feat: add 'tw_closed' channel to regex generator --- tests/module_factory.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/module_factory.py b/tests/module_factory.py index bfec09170d..e1cf310fd8 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -231,7 +231,10 @@ def create_regex_generator_obj(self, mock_db, store_dir="dummy_output_dir/regex_ ) regex_generator.db.channels.LLM_REQUEST = "llm_request" regex_generator.db.channels.LLM_RESPONSE = "llm_response" - regex_generator.channels = {"llm_response": regex_generator.c_llm} + regex_generator.channels = { + "llm_response": regex_generator.c_llm, + "tw_closed": regex_generator.c_tw_closed, + } regex_generator.print = Mock() return regex_generator From 7d3383fa1cd3e48a3d7e71b39141eabe86473c57 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Thu, 19 Mar 2026 10:47:03 +0000 Subject: [PATCH 0082/1100] feat: add tests for handling benign string imports based on host status --- .../regex_generator/test_regex_generator.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/unit/modules/regex_generator/test_regex_generator.py b/tests/unit/modules/regex_generator/test_regex_generator.py index 547961792b..61b0f90d38 100644 --- a/tests/unit/modules/regex_generator/test_regex_generator.py +++ b/tests/unit/modules/regex_generator/test_regex_generator.py @@ -261,6 +261,99 @@ def test_log_file_rotates_with_global_rotation_settings(tmp_path, mocker): regex_generator.shutdown_gracefully() +def test_clean_host_tw_imports_runtime_benign_strings(tmp_path, mocker): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + mocker.patch( + "modules.regex_generator.regex_generator.utils.drop_root_privs_permanently" + ) + regex_generator.pre_main() + regex_generator.get_msg = Mock( + side_effect=[ + {"data": "profile_192.168.1.10_timewindow7"}, + ] + ) + regex_generator.db.get_all_host_ips = Mock(return_value=["192.168.1.10"]) + regex_generator.db.get_profileid_twid_alerts = Mock(return_value={}) + regex_generator.db.get_twid_evidence = Mock(return_value={}) + regex_generator.db.get_all_altflows_in_profileid_twid = Mock( + return_value=[ + { + "flow_type": "dns", + "flow": {"query": "printer.example.org"}, + }, + { + "flow_type": "http", + "flow": {"host": "updates.example.org", "uri": "/downloads/setup.msi"}, + }, + { + "flow_type": "ssl", + "flow": { + "server_name": "api.github.com", + "subject": "C=US,O=GitHub,CN=github.com", + }, + }, + ] + ) + + regex_generator._handle_one_tw_closed_message() + + assert "printer.example.org" in set( + regex_generator.storage.iter_benign_strings("dns_domain") + ) + assert "updates.example.org" in set( + regex_generator.storage.iter_benign_strings("dns_domain") + ) + assert "setup.msi" in set( + regex_generator.storage.iter_benign_strings("filename") + ) + assert "api.github.com" in set( + regex_generator.storage.iter_benign_strings("tls_sni") + ) + assert "github.com" in set( + regex_generator.storage.iter_benign_strings("certificate_cn") + ) + regex_generator.shutdown_gracefully() + + +def test_dirty_host_tw_does_not_import_runtime_benign_strings(tmp_path, mocker): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + mocker.patch( + "modules.regex_generator.regex_generator.utils.drop_root_privs_permanently" + ) + regex_generator.pre_main() + before_dns = set(regex_generator.storage.iter_benign_strings("dns_domain")) + regex_generator.get_msg = Mock( + side_effect=[ + {"data": "profile_192.168.1.10_timewindow8"}, + ] + ) + regex_generator.db.get_all_host_ips = Mock(return_value=["192.168.1.10"]) + regex_generator.db.get_profileid_twid_alerts = Mock( + return_value={"alert-1": ["ev-1"]} + ) + regex_generator.db.get_twid_evidence = Mock( + return_value={"ev-1": json.dumps({"evidence_type": "MALICIOUS_FLOW"})} + ) + regex_generator.db.get_all_altflows_in_profileid_twid = Mock( + return_value=[ + { + "flow_type": "dns", + "flow": {"query": "should-not-be-added.example"}, + }, + ] + ) + + regex_generator._handle_one_tw_closed_message() + + after_dns = set(regex_generator.storage.iter_benign_strings("dns_domain")) + assert after_dns == before_dns + regex_generator.shutdown_gracefully() + + def test_build_prompt_messages_uses_type_specific_prompt(tmp_path): regex_generator = ModuleFactory().create_regex_generator_obj( store_dir=str(tmp_path / "regex_generator") From bac9c5050da4a8a52f7bb43cc68010a1fab16c8e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 09:27:54 +0000 Subject: [PATCH 0083/1100] feat: remove logging for rejected malformed LLM responses --- modules/regex_generator/regex_generator.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/modules/regex_generator/regex_generator.py b/modules/regex_generator/regex_generator.py index 31e68de9d2..a255bfea27 100644 --- a/modules/regex_generator/regex_generator.py +++ b/modules/regex_generator/regex_generator.py @@ -595,16 +595,6 @@ def _finalize_request(self, response: dict): llm_text = response.get("text", "") regex, rejection_reason = self._extract_regex_from_llm_text(llm_text) if rejection_reason: - self._log_detail( - f"Rejected malformed LLM response request_id=" - f"{self.pending_request['request_id']} reason={rejection_reason} " - f"raw_preview={self._short_preview(llm_text)!r}" - ) - self.print( - f"RegexGenerator rejected malformed LLM response: {rejection_reason}", - 0, - 1, - ) return record = { From 33186e7aca5c0025d966b585c09f050c1d30ee2a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 09:28:05 +0000 Subject: [PATCH 0084/1100] feat: add test to ensure malformed LLM responses are dropped without error logging --- .../regex_generator/test_regex_generator.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/modules/regex_generator/test_regex_generator.py b/tests/unit/modules/regex_generator/test_regex_generator.py index 61b0f90d38..ef1fc6d754 100644 --- a/tests/unit/modules/regex_generator/test_regex_generator.py +++ b/tests/unit/modules/regex_generator/test_regex_generator.py @@ -440,6 +440,33 @@ def test_handle_pending_response_keeps_waiting_after_soft_timeout( regex_generator.print.assert_called() +def test_finalize_request_drops_malformed_llm_response_without_error_logging( + tmp_path, +): + regex_generator = ModuleFactory().create_regex_generator_obj( + store_dir=str(tmp_path / "regex_generator") + ) + regex_generator.pending_request = { + "request_id": "req-1", + "regex_type": "dns_domain", + "backend": "local_qwen", + } + regex_generator._log_detail = Mock() + regex_generator._validate_and_store_regex = Mock() + + regex_generator._finalize_request( + { + "request_id": "req-1", + "success": True, + "text": "not json", + } + ) + + regex_generator.print.assert_not_called() + regex_generator._log_detail.assert_not_called() + regex_generator._validate_and_store_regex.assert_not_called() + + def test_extract_regex_from_llm_text_rejects_invalid_payloads(tmp_path): regex_generator = ModuleFactory().create_regex_generator_obj( store_dir=str(tmp_path / "regex_generator") From ca4b82acb495335f9ce59292ed499aa25bbef3e8 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 09:29:04 +0000 Subject: [PATCH 0085/1100] feat: implement regex specificity measurement and match strength computation --- modules/regex_generator/match_strength.py | 83 +++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 modules/regex_generator/match_strength.py diff --git a/modules/regex_generator/match_strength.py b/modules/regex_generator/match_strength.py new file mode 100644 index 0000000000..1c7a8b36aa --- /dev/null +++ b/modules/regex_generator/match_strength.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import re + + +def measure_regex_specificity(regex_text: str) -> dict: + literal_chars = 0 + meta_tokens = 0 + wildcard_points = 0.0 + idx = 0 + while idx < len(regex_text): + char = regex_text[idx] + next_char = regex_text[idx + 1] if idx + 1 < len(regex_text) else "" + + if char == "\\": + token = regex_text[idx : idx + 2] + meta_tokens += 1 + if len(token) == 2 and token[1] in ".^$*+?{}[]()|\\": + literal_chars += 1 + else: + wildcard_points += 1.0 + idx += 2 if next_char else 1 + continue + + if char.isalnum() or char in "-_/:,@=": + literal_chars += 1 + idx += 1 + continue + + meta_tokens += 1 + if char == "." and next_char in {"*", "+"}: + wildcard_points += 2.5 + elif char == ".": + wildcard_points += 1.5 + elif char == "[": + wildcard_points += 1.2 + elif char in {"*", "+", "?"}: + wildcard_points += 1.0 + idx += 1 + + effective_length = max(1, literal_chars + meta_tokens) + specificity_ratio = min(1.0, literal_chars / effective_length) + wildcard_penalty = min(1.0, wildcard_points / max(1.0, effective_length / 2)) + return { + "specificity_ratio": specificity_ratio, + "wildcard_penalty": wildcard_penalty, + } + + +def compute_match_strength( + compiled_regex: re.Pattern, + value: str, + regex_features: dict | None = None, +) -> float: + value = str(value or "") + if not value: + return 0.0 + + if regex_features is None: + regex_features = measure_regex_specificity(compiled_regex.pattern) + + best_score = 0.0 + value_len = max(1, len(value)) + for match in compiled_regex.finditer(value): + start, end = match.span() + span_len = max(0, end - start) + if span_len <= 0: + continue + + span_ratio = min(1.0, span_len / value_len) + start_bonus = 1.0 if start == 0 else 0.0 + end_bonus = 1.0 if end == len(value) else 0.0 + full_bonus = 1.0 if start == 0 and end == len(value) else 0.0 + score = ( + 40.0 * span_ratio + + 12.0 * start_bonus + + 12.0 * end_bonus + + 16.0 * full_bonus + + 30.0 * regex_features["specificity_ratio"] + - 18.0 * regex_features["wildcard_penalty"] + ) + best_score = max(best_score, max(0.0, min(100.0, score))) + return best_score From d1bff1c72c1321139c8f833506d273cbca8e8171 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:27:05 +0000 Subject: [PATCH 0086/1100] feat: add EvidenceSignals configuration for evidence handling --- config/slips.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/config/slips.yaml b/config/slips.yaml index 4b447b38fc..5cf7c9c699 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -639,6 +639,27 @@ DisabledAlerts: # disabled_detections = [THREAT_INTELLIGENCE_BLACKLISTED_IP] disabled_detections: [] +############################# +EvidenceSignals: + + # Slips adds an evidence_signal field to every evidence centrally when it + # reaches the shared evidence pipeline. Modules do not need to set it. + # + # Allowed values are: + # - PAMP + # - DAMP + # + # If an evidence type is not listed under overrides, Slips uses + # default_signal. Unknown or invalid entries also fall back to PAMP. + default_signal: PAMP + + # Override the evidence signal per evidence type. + # By default MALICIOUS_FLOW is marked as DAMP because it is emitted by the + # anomaly detection modules. Everything else defaults to PAMP unless you + # override it here. + overrides: + MALICIOUS_FLOW: DAMP + ############################# Docker: # ID and group id of the user who started to docker container From 6010de072532693b993600cb7c5a1cd956e386d3 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:27:12 +0000 Subject: [PATCH 0087/1100] feat: add Evidence Signals documentation for evidence handling and configuration --- docs/evidence_signals.md | 114 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/evidence_signals.md diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md new file mode 100644 index 0000000000..07c7b3d8a0 --- /dev/null +++ b/docs/evidence_signals.md @@ -0,0 +1,114 @@ +# Evidence Signals + +Slips now adds an `evidence_signal` field to every evidence when the evidence reaches the shared evidence pipeline. Detection modules do not need to set this field themselves. + +The supported values are: + +- `PAMP` +- `DAMP` + +Unknown evidence types default to `PAMP`. + +## Configuration + +Configure the default signal and per-evidence overrides in `config/slips.yaml`: + +```yaml +EvidenceSignals: + default_signal: PAMP + overrides: + MALICIOUS_FLOW: DAMP +``` + +Rules: + +- `default_signal` is applied to every evidence type that is not listed in `overrides`. +- `overrides` keys are evidence type names from `EvidenceType`. +- Invalid values fall back to `PAMP`. +- The default shipped mapping marks `MALICIOUS_FLOW` as `DAMP`. + +## Propagation + +The field is added centrally before the evidence is stored or published, so it is available consistently in: + +- Redis-stored evidence +- `alerts.json` +- STIX/TAXII export +- SlipsWeb dashboard payloads + +## Current Evidence Inventory + +The table below lists the evidence types currently emitted by Slips modules and their default signal classification. + +| Module | Evidence type | Default signal | +| --- | --- | --- | +| `anomaly_detection_https` | `MALICIOUS_FLOW` | `DAMP` | +| `arp` | `ARP_SCAN` | `PAMP` | +| `arp` | `ARP_OUTSIDE_LOCALNET` | `PAMP` | +| `arp` | `UNSOLICITED_ARP` | `PAMP` | +| `arp` | `MITM_ARP_ATTACK` | `PAMP` | +| `flowalerts` | `BAD_SMTP_LOGIN` | `PAMP` | +| `flowalerts` | `CN_URL_MISMATCH` | `PAMP` | +| `flowalerts` | `CONNECTION_TO_MULTIPLE_PORTS` | `PAMP` | +| `flowalerts` | `CONNECTION_TO_PRIVATE_IP` | `PAMP` | +| `flowalerts` | `CONNECTION_WITHOUT_DNS` | `PAMP` | +| `flowalerts` | `DATA_UPLOAD` | `PAMP` | +| `flowalerts` | `DEVICE_CHANGING_IP` | `PAMP` | +| `flowalerts` | `DGA_NXDOMAINS` | `PAMP` | +| `flowalerts` | `DIFFERENT_LOCALNET` | `PAMP` | +| `flowalerts` | `DNS_ARPA_SCAN` | `PAMP` | +| `flowalerts` | `DNS_WITHOUT_CONNECTION` | `PAMP` | +| `flowalerts` | `GRE_SCAN` | `PAMP` | +| `flowalerts` | `GRE_TUNNEL` | `PAMP` | +| `flowalerts` | `HIGH_ENTROPY_DNS_ANSWER` | `PAMP` | +| `flowalerts` | `HORIZONTAL_PORT_SCAN` | `PAMP` | +| `flowalerts` | `INCOMPATIBLE_CN` | `PAMP` | +| `flowalerts` | `INVALID_DNS_RESOLUTION` | `PAMP` | +| `flowalerts` | `LONG_CONNECTION` | `PAMP` | +| `flowalerts` | `MALICIOUS_JA3` | `PAMP` | +| `flowalerts` | `MALICIOUS_JA3S` | `PAMP` | +| `flowalerts` | `MALICIOUS_SSL_CERT` | `PAMP` | +| `flowalerts` | `MULTIPLE_RECONNECTION_ATTEMPTS` | `PAMP` | +| `flowalerts` | `MULTIPLE_SSH_VERSIONS` | `PAMP` | +| `flowalerts` | `NON_SSL_PORT_443_CONNECTION` | `PAMP` | +| `flowalerts` | `PASSWORD_GUESSING` | `PAMP` | +| `flowalerts` | `PASTEBIN_DOWNLOAD` | `PAMP` | +| `flowalerts` | `PORT_0_CONNECTION` | `PAMP` | +| `flowalerts` | `SELF_SIGNED_CERTIFICATE` | `PAMP` | +| `flowalerts` | `SMTP_LOGIN_BRUTEFORCE` | `PAMP` | +| `flowalerts` | `SSH_SUCCESSFUL` | `PAMP` | +| `flowalerts` | `UNKNOWN_PORT` | `PAMP` | +| `flowalerts` | `VERTICAL_PORT_SCAN` | `PAMP` | +| `flowalerts` | `YOUNG_DOMAIN` | `PAMP` | +| `flowmldetection` | `MALICIOUS_FLOW` | `DAMP` | +| `http_analyzer` | `EMPTY_CONNECTIONS` | `PAMP` | +| `http_analyzer` | `EXECUTABLE_MIME_TYPE` | `PAMP` | +| `http_analyzer` | `HTTP_TRAFFIC` | `PAMP` | +| `http_analyzer` | `INCOMPATIBLE_USER_AGENT` | `PAMP` | +| `http_analyzer` | `MULTIPLE_USER_AGENT` | `PAMP` | +| `http_analyzer` | `NON_HTTP_PORT_80_CONNECTION` | `PAMP` | +| `http_analyzer` | `PASTEBIN_DOWNLOAD` | `PAMP` | +| `http_analyzer` | `SUSPICIOUS_USER_AGENT` | `PAMP` | +| `http_analyzer` | `WEIRD_HTTP_METHOD` | `PAMP` | +| `ip_info` | `MALICIOUS_JARM` | `PAMP` | +| `leak_detector` | `NETWORK_GPS_LOCATION_LEAKED` | `PAMP` | +| `network_discovery` | `DHCP_SCAN` | `PAMP` | +| `network_discovery` | `ICMP_ADDRESS_MASK_SCAN` | `PAMP` | +| `network_discovery` | `ICMP_ADDRESS_SCAN` | `PAMP` | +| `network_discovery` | `ICMP_TIMESTAMP_SCAN` | `PAMP` | +| `network_discovery.horizontal_portscan` | `HORIZONTAL_PORT_SCAN` | `PAMP` | +| `network_discovery.vertical_portscan` | `VERTICAL_PORT_SCAN` | `PAMP` | +| `p2ptrust` | `MALICIOUS_IP_FROM_P2P_NETWORK` | `PAMP` | +| `p2ptrust` | `P2P_REPORT` | `PAMP` | +| `p2ptrust.go_director` | `P2P_REPORT` | `PAMP` | +| `rnn_cc_detection` | `COMMAND_AND_CONTROL_CHANNEL` | `PAMP` | +| `threat_intelligence` | `MALICIOUS_DOWNLOADED_FILE` | `PAMP` | +| `threat_intelligence` | `THREAT_INTELLIGENCE_ANSWER_OF_BLACKLISTED_QUERY` | `PAMP` | +| `threat_intelligence` | `THREAT_INTELLIGENCE_BLACKLISTED_ASN` | `PAMP` | +| `threat_intelligence` | `THREAT_INTELLIGENCE_BLACKLISTED_DNS_ANSWER` | `PAMP` | +| `threat_intelligence` | `THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN` | `PAMP` | +| `threat_intelligence` | `THREAT_INTELLIGENCE_TO_BLACKLISTED_IP` | `PAMP` | +| `threat_intelligence.urlhaus` | `MALICIOUS_DOWNLOADED_FILE` | `PAMP` | +| `threat_intelligence.urlhaus` | `THREAT_INTELLIGENCE_MALICIOUS_URL` | `PAMP` | + +`MALICIOUS_FLOW` is listed under both `anomaly_detection_https` and `flowmldetection` because both modules emit that evidence type. Since signal assignment is centralized by evidence type, both inherit the same default mapping unless overridden in configuration. From 1fe7a7486ea5ac0dbf10008741f945497c107505 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:27:18 +0000 Subject: [PATCH 0088/1100] feat: add Evidence signals section to documentation for evidence classification and configuration --- docs/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index f1ee6d185f..0ca40e076e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,6 +31,8 @@ This documentation gives an overview how Slips works, how to use it and how to h - **Exporting**. The exporting module allows Slips to export to Slack and STIX servers. See :doc:`Exporting `. +- **Evidence signals**. Central PAMP/DAMP classification for evidence, configuration overrides, and the current evidence inventory. See :doc:`Evidence signals `. + - **Slips in Action**. Example of using slips to analyze different PCAPs See :doc:`Slips in action `. - **Contributing**. Explanation how to contribute to Slips, and instructions how to implement new detection module in Slips. See :doc:`Contributing `. @@ -62,6 +64,7 @@ This documentation gives an overview how Slips works, how to use it and how to h features training exporting + evidence_signals P2P fides_module create_new_module From f8fd03db966439559664bb5c70f54a833081d72f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:27:23 +0000 Subject: [PATCH 0089/1100] feat: add evidence_signal property to custom properties and metadata in StixExporter --- modules/exporting_alerts/stix_exporter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/exporting_alerts/stix_exporter.py b/modules/exporting_alerts/stix_exporter.py index 8998c25752..93aef9b105 100644 --- a/modules/exporting_alerts/stix_exporter.py +++ b/modules/exporting_alerts/stix_exporter.py @@ -635,6 +635,8 @@ def _build_custom_properties( custom_properties: Dict[str, object] = { "x_slips_evidence_id": evidence.get("id"), "x_slips_threat_level": evidence.get("threat_level"), + "x_slips_evidence_signal": evidence.get("evidence_signal") + or "PAMP", "x_slips_profile_ip": profile.get("ip"), "x_slips_timewindow": timewindow.get("number"), "x_slips_attacker_direction": attacker.get("direction"), @@ -784,6 +786,7 @@ def _build_taxii1_package( threat_level = evidence.get("threat_level") if threat_level: meta["threat_level"] = threat_level + meta["evidence_signal"] = evidence.get("evidence_signal") or "PAMP" victim_value = (evidence.get("victim") or {}).get("value") if victim_value: meta["victim"] = victim_value From afcd84070b1c5fccf43b19035f854b96b0d2699a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:27:35 +0000 Subject: [PATCH 0090/1100] feat: add evidence_signal to evidence data structure in EvidenceHandler --- slips_files/core/evidence_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/slips_files/core/evidence_handler.py b/slips_files/core/evidence_handler.py index 0749d95feb..b0366e660e 100644 --- a/slips_files/core/evidence_handler.py +++ b/slips_files/core/evidence_handler.py @@ -176,6 +176,9 @@ def add_evidence_to_json_log_file( "uids": evidence.uid, "accumulated_threat_level": accumulated_threat_level, "threat_level": str(evidence.threat_level), + "evidence_signal": str( + evidence.evidence_signal + ), "timewindow": evidence.timewindow.number, } ) From 63f8e5d3ca0423d8f68d0c5c6bf3714b6d703047 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:27:46 +0000 Subject: [PATCH 0091/1100] feat: add default and overrides methods for evidence signals in ConfigParser --- slips_files/common/parsers/config_parser.py | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index d49f7a0086..0884dc9cfe 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -247,6 +247,36 @@ def disabled_detections(self) -> list: "DisabledAlerts", "disabled_detections", [] ) + def evidence_signal_default(self) -> str: + value = self.read_configuration( + "EvidenceSignals", "default_signal", "PAMP" + ) + if not isinstance(value, str): + return "PAMP" + value = value.strip().upper() + if value not in ("PAMP", "DAMP"): + return "PAMP" + return value + + def evidence_signal_overrides(self) -> dict: + overrides = self.read_configuration( + "EvidenceSignals", "overrides", {} + ) + if not isinstance(overrides, dict): + return {} + + sanitized = {} + for evidence_type, signal in overrides.items(): + if not isinstance(evidence_type, str): + continue + if not isinstance(signal, str): + continue + normalized_signal = signal.strip().upper() + if normalized_signal not in ("PAMP", "DAMP"): + continue + sanitized[evidence_type.strip().upper()] = normalized_signal + return sanitized + def get_tw_width(self) -> str: twid_width = self.get_tw_width_in_seconds() # timedelta puts it in the form of X days, hours:minutes:seconds From c32bb09250e0db550542289e1db1eb9949d64800 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:27:54 +0000 Subject: [PATCH 0092/1100] feat: implement evidence signal classification in AlertHandler --- .../core/database/redis_db/alert_handler.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/slips_files/core/database/redis_db/alert_handler.py b/slips_files/core/database/redis_db/alert_handler.py index dcd00eccb3..974bcbdd39 100644 --- a/slips_files/core/database/redis_db/alert_handler.py +++ b/slips_files/core/database/redis_db/alert_handler.py @@ -18,6 +18,7 @@ ) from slips_files.core.structures.evidence import ( Evidence, + EvidenceSignal, EvidenceType, Victim, IoCType, @@ -37,6 +38,8 @@ class AlertHandler: default_ttl: int width: float disabled_detections: Any + default_evidence_signal: str + evidence_signal_overrides: Dict[str, str] publish: Callable[..., Any] zadd_but_keep_n_entries: Callable[..., Any] get_tw_start_time: Callable[..., Any] @@ -115,6 +118,19 @@ def is_detection_disabled(self, evidence_type: EvidenceType): """ return str(evidence_type) in self.disabled_detections + def _classify_evidence_signal( + self, evidence_type: EvidenceType + ) -> EvidenceSignal: + evidence_type_name = str(evidence_type).upper() + signal = self.evidence_signal_overrides.get( + evidence_type_name, + self.default_evidence_signal, + ) + try: + return EvidenceSignal[str(signal).upper()] + except KeyError: + return EvidenceSignal.PAMP + def set_flow_causing_evidence(self, uids: list, evidence_id): """ Used to be able to add the "malicious" tag to the flows that caused @@ -261,6 +277,9 @@ def set_evidence(self, evidence: Evidence): self.add_profile(str(evidence.profile), evidence.timestamp) # normalize confidence, should range from 0 to 1 evidence.confidence = min(evidence.confidence, 1) + evidence.evidence_signal = self._classify_evidence_signal( + evidence.evidence_type + ) # Ignore evidence if it's disabled in the configuration file if self.is_detection_disabled(evidence.evidence_type): From c88df4f0ae8a1f60b7e317acc12b14ca2188caed Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:28:10 +0000 Subject: [PATCH 0093/1100] feat: add default evidence signal and overrides to RedisDB configuration --- slips_files/core/database/redis_db/database.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/slips_files/core/database/redis_db/database.py b/slips_files/core/database/redis_db/database.py index 9ead35f974..993eb13477 100644 --- a/slips_files/core/database/redis_db/database.py +++ b/slips_files/core/database/redis_db/database.py @@ -279,6 +279,10 @@ def _read_configuration(cls): # By default False. Meaning we don't DELETE the DB by default. cls.config_flush_db: bool = conf.delete_prev_db() cls.disabled_detections: List[str] = conf.disabled_detections() + cls.default_evidence_signal: str = conf.evidence_signal_default() + cls.evidence_signal_overrides: dict = ( + conf.evidence_signal_overrides() + ) cls.width = conf.get_tw_width_in_seconds() cls.client_ips: List[str] = conf.client_ips() From 61e273e2fbc60a462915660644f1c01f52018a8c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:28:18 +0000 Subject: [PATCH 0094/1100] feat: add EvidenceSignal enum and integrate into Evidence class --- slips_files/core/structures/evidence.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/slips_files/core/structures/evidence.py b/slips_files/core/structures/evidence.py index b05b45c5fe..7952a48564 100644 --- a/slips_files/core/structures/evidence.py +++ b/slips_files/core/structures/evidence.py @@ -139,6 +139,14 @@ def __str__(self): return self.name.lower() +class EvidenceSignal(Enum): + PAMP = "PAMP" + DAMP = "DAMP" + + def __str__(self): + return self.name + + class Proto(Enum): TCP = "tcp" UDP = "udp" @@ -303,6 +311,7 @@ class Evidence: ) }, ) + evidence_signal: EvidenceSignal = field(default=EvidenceSignal.PAMP) def __post_init__(self): if not isinstance(self.uid, list) or not all( @@ -331,6 +340,7 @@ def __str__(self): f" ID: {self.id},\n" f" Confidence: {self.confidence},\n" f" Related ID: {self.rel_id}\n" + f" Evidence Signal: {self.evidence_signal}\n" f")" ) @@ -341,6 +351,13 @@ def dict_to_evidence(evidence: dict) -> Evidence: :param evidence: Dictionary with evidence details. returns an instance of the Evidence class. """ + try: + evidence_signal = EvidenceSignal[ + str(evidence.get("evidence_signal", "PAMP")).upper() + ] + except KeyError: + evidence_signal = EvidenceSignal.PAMP + evidence_attributes = { "evidence_type": EvidenceType[evidence["evidence_type"]], "description": evidence["description"], @@ -371,6 +388,7 @@ def dict_to_evidence(evidence: dict) -> Evidence: "rel_id": evidence["rel_id"], "confidence": evidence["confidence"], "method": Method[evidence["method"].upper()], + "evidence_signal": evidence_signal, } return Evidence(**evidence_attributes) From b67817dea72fa0868611e01586d5361b91b0c26c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:28:26 +0000 Subject: [PATCH 0095/1100] feat: add default and override evidence signals in ModuleFactory --- tests/module_factory.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/module_factory.py b/tests/module_factory.py index e1cf310fd8..09c25dc2e5 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -68,6 +68,10 @@ def create_db_manager_obj( conf = Mock() conf.delete_prev_db = Mock(return_value=False) conf.disabled_detections = Mock(return_value=[]) + conf.evidence_signal_default = Mock(return_value="PAMP") + conf.evidence_signal_overrides = Mock( + return_value={"MALICIOUS_FLOW": "DAMP"} + ) conf.get_tw_width_as_float = Mock(return_value=3600.0) conf.get_tw_width_in_seconds = Mock(return_value=3600) conf.client_ips = Mock(return_value=[]) @@ -1064,6 +1068,10 @@ def create_alert_handler_obj(self): alert_handler.constants = Constants() alert_handler.default_ttl = 3600 alert_handler.extended_ttl = 3600 + alert_handler.default_evidence_signal = "PAMP" + alert_handler.evidence_signal_overrides = { + "MALICIOUS_FLOW": "DAMP" + } alert_handler.set_profileid_field = Mock() return alert_handler From d98ba3072b22e3ff14ae01df85d252d864e5d702 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:28:39 +0000 Subject: [PATCH 0096/1100] feat: add unit tests for evidence signal default and overrides in ConfigParser --- .../slips_files/common/test_config_parser.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/unit/slips_files/common/test_config_parser.py diff --git a/tests/unit/slips_files/common/test_config_parser.py b/tests/unit/slips_files/common/test_config_parser.py new file mode 100644 index 0000000000..5ca202ced4 --- /dev/null +++ b/tests/unit/slips_files/common/test_config_parser.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +from slips_files.common.parsers.config_parser import ConfigParser + + +def test_evidence_signal_default_falls_back_to_pamp(): + parser = ConfigParser.__new__(ConfigParser) + parser.config = {"EvidenceSignals": {"default_signal": "invalid"}} + + assert parser.evidence_signal_default() == "PAMP" + + +def test_evidence_signal_overrides_sanitizes_values(): + parser = ConfigParser.__new__(ConfigParser) + parser.config = { + "EvidenceSignals": { + "overrides": { + "malicious_flow": "damp", + "ssh_successful": "PAMP", + "bad_type": "invalid", + 123: "DAMP", + } + } + } + + assert parser.evidence_signal_overrides() == { + "MALICIOUS_FLOW": "DAMP", + "SSH_SUCCESSFUL": "PAMP", + } From 4e342451aa894970dc950e54e88f506813f09beb Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:28:47 +0000 Subject: [PATCH 0097/1100] feat: add test for evidence signal inclusion in JSON log file --- .../slips_files/core/test_evidence_handler.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/unit/slips_files/core/test_evidence_handler.py b/tests/unit/slips_files/core/test_evidence_handler.py index 0feaad7f3b..680b272311 100644 --- a/tests/unit/slips_files/core/test_evidence_handler.py +++ b/tests/unit/slips_files/core/test_evidence_handler.py @@ -4,11 +4,13 @@ import pytest from unittest.mock import Mock, MagicMock, patch +import json from slips_files.core.structures.alerts import Alert from slips_files.core.structures.evidence import ( Evidence, ProfileID, + EvidenceSignal, EvidenceType, TimeWindow, Attacker, @@ -235,6 +237,42 @@ def test_add_alert_to_json_log_file( ) +def test_add_evidence_to_json_log_file_includes_evidence_signal(): + evidence_handler = ModuleFactory().create_evidence_handler_obj() + evidence_handler.idmefv2.convert_to_idmef_event = Mock( + return_value={"Category": "Intrusion.Detection"} + ) + evidence_handler.evidence_logger_q.put = Mock() + + evidence = Evidence( + evidence_type=EvidenceType.MALICIOUS_FLOW, + description="Anomalous HTTPS flow", + attacker=Attacker( + direction=Direction.SRC, + ioc_type=IoCType.IP, + value="192.168.1.20", + ), + threat_level=ThreatLevel.HIGH, + profile=ProfileID("192.168.1.20"), + timewindow=TimeWindow(1), + uid=["uid-1"], + timestamp="2024/10/04 15:45:30.123456+0000", + evidence_signal=EvidenceSignal.DAMP, + ) + + evidence_handler.add_evidence_to_json_log_file( + evidence, accumulated_threat_level=1.2 + ) + + evidence_handler.evidence_logger_q.put.assert_called_once() + logged_event = evidence_handler.evidence_logger_q.put.call_args.args[0] + note = json.loads(logged_event["to_log"]["Note"]) + assert logged_event["where"] == "alerts.json" + assert note["evidence_signal"] == "DAMP" + assert note["threat_level"] == "high" + assert note["timewindow"] == 1 + + def test_show_popup(): evidence_handler = ModuleFactory().create_evidence_handler_obj() evidence_handler.notify = Mock() From 27aa046fb680ad0b9db1c118138b43fb032878d5 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:28:56 +0000 Subject: [PATCH 0098/1100] feat: enhance test_set_evidence to validate evidence_signal based on evidence_type --- .../database/redis_db/test_alert_handler.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/unit/slips_files/core/database/redis_db/test_alert_handler.py b/tests/unit/slips_files/core/database/redis_db/test_alert_handler.py index 0997fac617..7966873b90 100644 --- a/tests/unit/slips_files/core/database/redis_db/test_alert_handler.py +++ b/tests/unit/slips_files/core/database/redis_db/test_alert_handler.py @@ -12,6 +12,7 @@ ProfileID, TimeWindow, Evidence, + EvidenceSignal, EvidenceType, Attacker, Direction, @@ -296,7 +297,20 @@ def test_init_evidence_number(initial_value, expected_value): (None, True, False), # whitelisted → ignored ], ) -def test_set_evidence(evidence_exists, whitelisted, expected): +@pytest.mark.parametrize( + "evidence_type, expected_signal", + [ + (EvidenceType.SSH_SUCCESSFUL, EvidenceSignal.PAMP), + (EvidenceType.MALICIOUS_FLOW, EvidenceSignal.DAMP), + ], +) +def test_set_evidence( + evidence_exists, + whitelisted, + expected, + evidence_type, + expected_signal, +): db = ModuleFactory().create_alert_handler_obj() db.add_profile = Mock() @@ -331,7 +345,7 @@ def test_set_evidence(evidence_exists, whitelisted, expected): ) evidence = Evidence( - evidence_type=EvidenceType.SSH_SUCCESSFUL, + evidence_type=evidence_type, attacker=attacker, victim=victim, threat_level=ThreatLevel.INFO, @@ -346,11 +360,16 @@ def test_set_evidence(evidence_exists, whitelisted, expected): result = db.set_evidence(evidence) assert result is expected + assert evidence.evidence_signal == expected_signal if expected: db.r.hset.assert_called_once() db.r.incr.assert_called_once_with(db.constants.NUMBER_OF_EVIDENCE) db.publish.assert_called_once() + stored_evidence = json.loads(db.r.hset.call_args.args[2]) + published_evidence = json.loads(db.publish.call_args.args[1]) + assert stored_evidence["evidence_signal"] == expected_signal.name + assert published_evidence["evidence_signal"] == expected_signal.name else: db.r.hset.assert_not_called() db.publish.assert_not_called() From 9dfdddf27ec02866b524aaab838ebe7d2d8d3d07 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 10:29:03 +0000 Subject: [PATCH 0099/1100] feat: add evidence_signal validation and conversion tests in test_evidence --- .../core/structures/test_evidence.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/unit/slips_files/core/structures/test_evidence.py b/tests/unit/slips_files/core/structures/test_evidence.py index 6b79a46dda..14265de21e 100644 --- a/tests/unit/slips_files/core/structures/test_evidence.py +++ b/tests/unit/slips_files/core/structures/test_evidence.py @@ -8,6 +8,7 @@ Attacker, Direction, Evidence, + EvidenceSignal, EvidenceType, IoCType, ProfileID, @@ -110,6 +111,7 @@ def test_evidence_post_init( assert evidence.dst_port == port assert evidence.id == id assert evidence.confidence == confidence + assert evidence.evidence_signal == EvidenceSignal.PAMP def test_evidence_post_init_invalid_uid(): @@ -258,6 +260,69 @@ def test_evidence_to_dict( assert evidence_dict["dst_port"] == port assert evidence_dict["id"] == id assert evidence_dict["confidence"] == confidence + assert evidence_dict["evidence_signal"] == EvidenceSignal.PAMP.name + + +@pytest.mark.parametrize( + "raw_signal, expected_signal", + [ + ("DAMP", EvidenceSignal.DAMP), + ("damp", EvidenceSignal.DAMP), + ("unknown", EvidenceSignal.PAMP), + (None, EvidenceSignal.PAMP), + ], +) +def test_dict_to_evidence_signal(raw_signal, expected_signal): + from slips_files.core.structures.evidence import dict_to_evidence + + evidence_dict = { + "evidence_type": "ARP_SCAN", + "description": "ARP scan detected", + "interface": "eth0", + "attacker": { + "direction": "SRC", + "ioc_type": "IP", + "value": "192.168.1.1", + "profile": "", + "TI": None, + "AS": None, + "rDNS": None, + "SNI": None, + "DNS_resolution": None, + "queries": None, + "CNAME": None, + }, + "threat_level": "info", + "victim": { + "direction": "DST", + "ioc_type": "IP", + "value": "8.8.8.8", + "TI": None, + "AS": None, + "rDNS": None, + "SNI": None, + "DNS_resolution": None, + "queries": None, + "CNAME": None, + }, + "profile": {"ip": "192.168.1.1"}, + "timewindow": {"number": 1}, + "uid": ["uid-1"], + "timestamp": "2023/10/26 10:10:10.000000+0000", + "proto": "TCP", + "dst_port": 80, + "src_port": 12345, + "id": "d243119b-2aae-4d7a-8ea1-edf3c6e72f4a", + "rel_id": None, + "confidence": 0.8, + "method": "heuristic", + } + if raw_signal is not None: + evidence_dict["evidence_signal"] = raw_signal + + evidence = dict_to_evidence(evidence_dict) + + assert evidence.evidence_signal == expected_signal def test_validate_timestamp(): From 14cc3e1e8f61d2921cd647bd6c29d74a06f19c46 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:28:49 +0000 Subject: [PATCH 0100/1100] feat: update README to include T Cell module details and PAMP evidence handling --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 1f66f06f8c..090e8cd164 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ Slips has a [config/slips.yaml](https://github.com/stratosphereips/StratosphereL * You can also specify whether to ```train``` or ```test``` the ML models * You can enable [popup notifications](https://stratospherelinuxips.readthedocs.io/en/develop/usage.html#popup-notifications) of evidence, enable [blocking](https://stratospherelinuxips.readthedocs.io/en/develop/usage.html#slips-permissions), [plug in your own zeek script](https://stratospherelinuxips.readthedocs.io/en/develop/usage.html#plug-in-a-zeek-script) and more. +* You can enable the `t_cell` section to consume `PAMP` evidence, match extracted antigens against accepted regexes, and escalate to blocking or memory while keeping all responder state in its own SQLite DB. [More details about the config file options here]( https://stratospherelinuxips.readthedocs.io/en/develop/usage.html#modifying-the-configuration-file) @@ -187,6 +188,7 @@ Slips key features are: * **Integration with External Platforms**: Modules in Slips can look up IP addresses on external platforms such as VirusTotal and RiskIQ. * **Shared LLM Access**: Slips can expose configured LLM backends such as Ollama, OpenAI, and Anthropic to other modules through Redis channels. * **Pseudo-Random Regex Generation**: Slips can generate and validate pseudo-random regexes for DNS domains, URIs, filenames, TLS SNI, and certificate CN fields for later Zeek-side use. +* **Immune-Style T Cell Response**: Slips can consume centrally tagged `PAMP` evidence, correlate it with accepted regexes, and escalate to blocking or long-term memory through the new T Cell module. * **Graphical User Interface**: Slips provides a console graphical user interface (Kalipso) and a web interface for displaying detection with graphs and tables. * **Peer-to-Peer (P2P) Module**: Slips includes a complex automatic system to find other peers in the network and share IoC data automatically in a balanced, trusted manner. The P2P module can be enabled as needed. * **Docker Implementation**: Running Slips through Docker on Linux systems is simplified, allowing real-time traffic analysis. @@ -218,6 +220,8 @@ We appreciate your contributions and thank you for helping to improve Slips! # Documentation [User documentation](https://stratospherelinuxips.readthedocs.io/en/develop/) +T Cell design and configuration: [docs/t_cell_module.md](docs/t_cell_module.md) + [Code docs](https://stratospherelinuxips.readthedocs.io/en/develop/code_documentation.html ) --- From 47e275acf40e0bd34cd8687665e471d5a8917a84 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:28:56 +0000 Subject: [PATCH 0101/1100] feat: add T Cell responder module configuration to slips.yaml --- config/slips.yaml | 78 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/config/slips.yaml b/config/slips.yaml index 5cf7c9c699..268a8121e9 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -330,6 +330,84 @@ regex_generator: # certificate_cn checks. seed_benign_samples: true +############################# +t_cell: + # Enable the immune-inspired T Cell responder module. + enabled: false + + # Create output/t_cell.log with human-readable transition logs. + create_log_file: true + + # Keep ANSI colors in the T Cell log file for quick scanning. + log_colors: true + + # Directory that stores the isolated T Cell SQLite database. + # Absolute paths are used as-is. Relative paths are resolved inside the + # output directory of the current Slips run. + store_dir: output/t_cell + + # Optional stable absolute directory for the T Cell SQLite database. + persistent_store_dir: "" + + # Keep processed evidence observations for this many seconds. + observation_retention_seconds: 604800 + + # How long a cell remains anergic after a PAMP does not match any accepted + # regex for its antigen. + anergy_ttl_seconds: 21600 + + # Time window used to count related PAMP observations for the same profile. + related_lookback_seconds: 3600 + + # Saturation point for related PAMP scoring. A value of 5 means that 5 or + # more related observations contribute the full score. + related_pamps_saturation: 5 + + # Saturation point for weighted profile danger: + # sum(threat_level_value * confidence) / danger_saturation + danger_saturation: 2.5 + + # Activation threshold for co-stimulation. + co_stimulation_threshold: 0.65 + + # Co-stimulation weights are normalized internally. + co_stimulation_weights: + confidence: 0.35 + related_pamps: 0.25 + danger: 0.40 + + # Novelty lookback window used to decide whether a matched regex is new. + novelty_window_seconds: 86400 + + # Recent context window. The previous context window is the immediately + # preceding window of the same size. + context_recent_window_seconds: 1800 + + # Context threshold for escalating to the effector state. + effector_threshold: 0.70 + + # Minimum number of related recent observations required before an effector + # response is allowed. + effector_min_related_count: 4 + + # Cooldown between effector responses for the same T Cell. + effector_cooldown_seconds: 1800 + + # Context threshold for moving into the memory state. + memory_threshold: 0.60 + + # Maximum recent/previous pressure ratio to consider the threat to be + # decreasing enough for memory. + memory_trend_ratio_max: 0.60 + + # Minimum number of related recent observations required before memory is + # stored. + memory_min_related_count: 3 + + # If blocking or ARP-poisoning modules are not running, log a simulated + # effector action instead of publishing new_blocking. + simulate_effector_without_blocking: true + ############################# flowmldetection: # This is a module that uses machine learning for detection. From 9e04c9b3be804a5900de538794b95e3ec582c801 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:29:04 +0000 Subject: [PATCH 0102/1100] feat: add T Cell module description and details to detection_modules.md --- docs/detection_modules.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/detection_modules.md b/docs/detection_modules.md index 28d156a67d..18dd3ee497 100644 --- a/docs/detection_modules.md +++ b/docs/detection_modules.md @@ -133,6 +133,11 @@ tr:nth-child(even) { shared service module that continuously generates pseudo-random regexes, rejects those matching benign corpora, and stores accepted regexes for later modules ✅ + + T Cell + immune-style responder that consumes PAMP evidence, matches accepted regexes, and escalates to blocking or memory using a per-antigen state machine + ✅ + @@ -157,6 +162,20 @@ a benign corpus, and stores accepted regexes in a local SQLite database. For the full configuration, acceptance pipeline, and DB helper usage, see [Regex Generator Module](regex_generator_module.md). +## T Cell Module + +The T Cell module is a second-stage immune responder for Slips. + +It listens on `evidence_added`, uses the central `evidence_signal` field, +extracts structured antigens from evidence and linked altflows, and checks +those values against accepted regexes already stored by `RegexGenerator`. +Depending on co-stimulation and context signals, it becomes tolerant, +activates, requests containment over `new_blocking`, or stores memory in its +own SQLite DB. + +For the full state machine, formulas, DB schema, and configuration, see +[T Cell Module](t_cell_module.md). + ## HTTPS Anomaly Detection Module For the full technical description of the HTTPS anomaly detector (features, training, adaptation, z-score logic, evidence format, and configuration), see: From 5dbd7d5d2881e6aa2d219bfb1b140bacaa922d8f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:29:13 +0000 Subject: [PATCH 0103/1100] feat: update evidence_signals.md to clarify T Cell module's use of evidence_signal --- docs/evidence_signals.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md index 07c7b3d8a0..b696e3662d 100644 --- a/docs/evidence_signals.md +++ b/docs/evidence_signals.md @@ -2,6 +2,11 @@ Slips now adds an `evidence_signal` field to every evidence when the evidence reaches the shared evidence pipeline. Detection modules do not need to set this field themselves. +The `T Cell` module consumes this same central field and, in v1, only activates +its state machine for `PAMP` evidence. `DAMP` evidence is still stored by the +module as an observation but is ignored for activation. See +[T Cell Module](t_cell_module.md) for the responder details. + The supported values are: - `PAMP` From 8e13892a9578e78157ac8d93c6f3303521bb108c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:29:28 +0000 Subject: [PATCH 0104/1100] feat: add T Cell module to index.rst documentation --- docs/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index 0ca40e076e..7172cc6daf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,6 +21,8 @@ This documentation gives an overview how Slips works, how to use it and how to h - **Regex Generator module**. Shared service that generates and validates pseudo-random regexes for later Zeek-side use. See :doc:`Regex Generator module `. +- **T Cell module**. Immune-style responder that consumes PAMP evidence, regex matches, and context to decide blocking or memory. See :doc:`T Cell module `. + - **HTTPS anomaly detection**. Detailed design and behavior of the HTTPS anomaly detector. See :doc:`HTTPS anomaly detection `. - **Architecture**. Internal architecture of Slips (profiles, timewindows), the use of Zeek and connection to Redis. See :doc:`Architecture `. @@ -59,6 +61,7 @@ This documentation gives an overview how Slips works, how to use it and how to h detection_modules llm_module regex_generator_module + t_cell_module https_anomaly_detection flowalerts features From 30af7fae644cdcfb1250b2e8e10e1fc29449af3c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:29:36 +0000 Subject: [PATCH 0105/1100] feat: add T Cell module documentation detailing functionality and configuration --- docs/t_cell_module.md | 356 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/t_cell_module.md diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md new file mode 100644 index 0000000000..e0b7c6d03f --- /dev/null +++ b/docs/t_cell_module.md @@ -0,0 +1,356 @@ +# T Cell Module + +The `T Cell` module is an immune-inspired responder that consumes centrally +classified Slips evidence, looks for `PAMP`-tagged antigens that match the +accepted RegexGenerator regex corpus, and then escalates through a small state +machine until it either becomes tolerant, publishes a containment request, or +stores a memory snapshot for later reuse. + +The module is started by the normal Slips module loader when +`t_cell.enabled: true`. + +## Goals + +The module adds a second-stage decision layer without changing detector +modules: + +1. It listens to the shared `evidence_added` channel. +2. It ignores `DAMP` evidence for activation in v1. +3. It extracts structured antigen values from evidence and linked altflows. +4. It matches those values against accepted regexes already stored by + `RegexGenerator`. +5. It computes co-stimulation and context scores. +6. It either becomes tolerant, activates, requests blocking, or stores memory. + +The target of any effector response is always `evidence.profile.ip`, matching +the existing Slips blocking path. + +## State Machine + +One T Cell is tracked per: + +- target profile IP +- regex type +- normalized antigen value + +The persisted states are: + +- `0 - mature` +- `1 - antigen-recognized` +- `2 - anergic` +- `3 - activated` +- `4 - effector` +- `5 - memory` + +The runtime flow is: + +1. Slips publishes an evidence on `evidence_added`. +2. The module stores one observation row in its own SQLite DB. +3. If the evidence signal is not `PAMP`, the module logs `ignored_non_pamp` + and stops for that evidence. +4. If no structured antigen can be extracted, the module logs + `no_antigen_extracted` and stops for that evidence. +5. For each antigen candidate, the module loads or creates the cell in + `0 - mature`. +6. If the cell is still under `anergic_until`, the module logs suppression and + does nothing else. +7. If the cell is `2 - anergic` and the TTL expired, it transitions back to + `0 - mature`. +8. If no accepted regex matches the antigen, the cell goes `0 -> 2` and stores + a new `anergic_until`. +9. If a regex matches, the cell goes `0 -> 1` and stores the chosen regex + metadata. +10. The module computes co-stimulation. +11. If co-stimulation crosses the configured threshold, the cell goes `1 -> 3`. +12. In state `3`, the module computes context signals. +13. If the situation is novel and intense enough, the cell goes to + `4 - effector`. +14. If the situation is familiar and clearly cooling down, the cell goes to + `5 - memory`. + +State `4` publishes the existing `new_blocking` payload when blocking support +is present. If blocking or ARP poisoning modules are not running, the module +can simulate the effector decision and log the exact payload instead. + +State `5` stores the matched regex and the full context snapshot in the T Cell +SQLite DB. It does not emit a new Slips evidence. + +## Antigen Extraction + +The module reuses the same field semantics already used by RegexGenerator. + +Supported antigen types: + +- `dns_domain` +- `uri` +- `filename` +- `tls_sni` +- `certificate_cn` + +Extraction sources: + +- evidence attacker or victim domain values -> `dns_domain` +- evidence attacker URL values -> hostname as `dns_domain`, path as `uri`, + basename as `filename` +- evidence attacker or victim `SNI` -> `tls_sni` +- DNS altflow `query` -> `dns_domain` +- HTTP altflow `host` -> `dns_domain` +- HTTP altflow `uri` -> `uri` +- HTTP altflow URI basename -> `filename` +- SSL altflow `server_name` -> `tls_sni` +- SSL altflow `subject` `CN=` -> `certificate_cn` + +If a `PAMP` has no structured antigen, the module logs and skips it. It does +not create an anergic cell for that case. + +## Regex Matching + +Matching only uses accepted regexes already stored by `RegexGenerator`. + +For one antigen candidate: + +- the module loads accepted regexes of the same `regex_type` +- it keeps only those that actually match the antigen value +- it ranks them by strongest match strength against the antigen +- it uses regex specificity and then newest `created_at` as tie-breakers + +The chosen regex metadata is stored in the cell, transitions table, and any +memory row. + +## Co-Stimulation + +Co-stimulation measures how dangerous the current situation looks for the +matched antigen: + +```text +co_stimulation = + wc * confidence + + wr * related_pamp_score + + wd * profile_danger_score +``` + +Where: + +- `confidence = current evidence.confidence` +- `related_pamp_score = min(1, related_pamp_count / related_pamps_saturation)` +- `profile_danger_score = min(1, sum(threat_level_value * confidence) / danger_saturation)` + +Related PAMPs are recent `PAMP` observations for the same `profile.ip` that +share either: + +- the same antigen value, or +- the same matched regex hash + +Default weights are normalized from configuration: + +- `confidence = 0.35` +- `related_pamps = 0.25` +- `danger = 0.40` + +Default activation threshold: + +- `co_stimulation_threshold = 0.65` + +## Context Signals + +Context signals decide how to respond once a cell is activated. + +Definitions: + +- `novelty_score = 1` when the matched regex has no stored memory row and no + recent prior regex activity in `novelty_window_seconds`; otherwise `0` +- `recent_pressure` is the normalized danger score over + `context_recent_window_seconds` +- `previous_pressure` is the same normalized danger score over the previous + adjacent context window +- `trend_ratio = recent_pressure / max(previous_pressure, 0.01)` +- `recent_related_score = min(1, recent_related_count / related_pamps_saturation)` +- `decrease_score = clamp(1 - trend_ratio, 0, 1)` +- `familiarity_score = 1 - novelty_score` +- `stability_score = min(1, recent_related_count / memory_min_related_count)` + +Effector score: + +```text +effector_score = + 0.45 * recent_pressure + + 0.25 * recent_related_score + + 0.30 * novelty_score +``` + +Memory score: + +```text +memory_score = + 0.60 * decrease_score + + 0.25 * familiarity_score + + 0.15 * stability_score +``` + +Default decisions: + +- `effector` requires: + - `effector_score >= 0.70` + - `recent_related_count >= 4` + - novelty still present +- `memory` requires: + - `memory_score >= 0.60` + - `trend_ratio <= 0.60` + - `recent_related_count >= 3` + - familiarity already present + +If both would pass, `effector` wins. + +## Containment Behavior + +When the cell reaches `4 - effector`, the module publishes the same payload +shape used by the existing Slips blocking path: + +```json +{ + "ip": "", + "block": true, + "tw": 1, + "interface": null +} +``` + +Notes: + +- `ip` is always `evidence.profile.ip` +- `tw` is `evidence.timewindow.number` +- `interface` uses the same `utils.get_interface_of_ip()` lookup as the rest + of Slips +- `from` and `to` are omitted, so the existing blocking module falls back to + blocking both directions +- the same cell is rate-limited with `effector_cooldown_seconds` + +If no blocking-capable module is running: + +- with `simulate_effector_without_blocking: true`, the module logs a simulated + effector decision and the exact would-be payload +- with `false`, it keeps the state but only logs that the effector path is not + available + +## SQLite Storage + +The T Cell module uses its own isolated SQLite DB and does not change the core +Slips evidence schema, Redis evidence payloads, `alerts.json`, STIX/TAXII +export, or SlipsWeb payloads. + +Default DB location: + +```text +/t_cell/t_cell.sqlite +``` + +Tables: + +- `observations`: one processed evidence row with confidence, threat level, + extracted antigens, matched regexes, and the raw evidence JSON +- `cells`: current state for each `profile_ip + regex_type + antigen_value` +- `transitions`: auditable state transitions with reasons and score snapshots +- `memories`: stored state-5 regex/context snapshots + +The DB is accessed through `DBManager.get_t_cell_storage()`. + +## Logging + +If `create_log_file` is enabled, the module writes: + +```text +output/t_cell.log +``` + +The log is intentionally short and human-readable. It writes one line per +decision or transition, with: + +- timestamp +- action +- resulting state +- evidence type and ID +- profile IP +- cell key +- matched regex hash and value when relevant +- main scores + +Color mapping: + +- `0 - mature` -> cyan +- `1 - antigen-recognized` -> yellow +- `2 - anergic` -> blue +- `3 - activated` -> magenta +- `4 - effector` -> red +- `5 - memory` -> green + +## Configuration + +Example section from `config/slips.yaml`: + +```yaml +t_cell: + enabled: false + create_log_file: true + log_colors: true + store_dir: output/t_cell + persistent_store_dir: "" + observation_retention_seconds: 604800 + anergy_ttl_seconds: 21600 + related_lookback_seconds: 3600 + related_pamps_saturation: 5 + danger_saturation: 2.5 + co_stimulation_threshold: 0.65 + co_stimulation_weights: + confidence: 0.35 + related_pamps: 0.25 + danger: 0.40 + novelty_window_seconds: 86400 + context_recent_window_seconds: 1800 + effector_threshold: 0.70 + effector_min_related_count: 4 + effector_cooldown_seconds: 1800 + memory_threshold: 0.60 + memory_trend_ratio_max: 0.60 + memory_min_related_count: 3 + simulate_effector_without_blocking: true +``` + +Reference: + +- `enabled`: enable or disable the module +- `create_log_file`: create `output/t_cell.log` +- `log_colors`: keep ANSI colors in the module log +- `store_dir`: run-local directory for the SQLite DB +- `persistent_store_dir`: optional stable absolute directory for the DB +- `observation_retention_seconds`: retention for observation rows +- `anergy_ttl_seconds`: how long a non-matching cell remains tolerant +- `related_lookback_seconds`: lookback for co-stimulation correlation +- `related_pamps_saturation`: saturation point for related PAMP score +- `danger_saturation`: saturation point for weighted profile danger +- `co_stimulation_threshold`: threshold for `1 -> 3` +- `co_stimulation_weights`: normalized internally +- `novelty_window_seconds`: window for novelty suppression +- `context_recent_window_seconds`: context window size +- `effector_threshold`: minimum effector score +- `effector_min_related_count`: minimum related count before effector +- `effector_cooldown_seconds`: per-cell effector cooldown +- `memory_threshold`: minimum memory score +- `memory_trend_ratio_max`: maximum recent/previous pressure ratio for memory +- `memory_min_related_count`: minimum related count before memory +- `simulate_effector_without_blocking`: log a simulated effector action when + blocking modules are absent + +## Evidence Signal Dependency + +The module relies on the central `evidence_signal` field that Slips adds before +evidence is stored or published. + +See [Evidence Signals](evidence_signals.md) for: + +- the global `PAMP` / `DAMP` configuration +- the current evidence inventory by module +- the default shipped signal mapping + +T Cell v1 only activates on `PAMP`. `DAMP` observations are still stored in +the T Cell observation table for auditing, but they do not advance the state +machine. From 52f64106e73614d58cc21a7dc93b52be3afb3174 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:29:46 +0000 Subject: [PATCH 0106/1100] feat: add license and copyright information to T Cell module __init__.py --- modules/t_cell/__init__.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 modules/t_cell/__init__.py diff --git a/modules/t_cell/__init__.py b/modules/t_cell/__init__.py new file mode 100644 index 0000000000..f436f14183 --- /dev/null +++ b/modules/t_cell/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only From 78df8b5384e40e7efc9e9d259488553a1c3fcfb8 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:30:14 +0000 Subject: [PATCH 0107/1100] feat: add README for T Cell module detailing functionality and usage --- modules/t_cell/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 modules/t_cell/README.md diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md new file mode 100644 index 0000000000..1d044fa8f6 --- /dev/null +++ b/modules/t_cell/README.md @@ -0,0 +1,29 @@ +# T Cell Module + +`modules/t_cell/t_cell.py` implements an immune-inspired responder for Slips. + +It does not modify detector modules. Instead, it subscribes to the shared +`evidence_added` channel, reads the centrally assigned `evidence_signal`, and +creates one T Cell per: + +- `profile.ip` +- regex type +- normalized antigen value + +Main behavior: + +- only `PAMP` evidence activates the module in v1 +- antigens are extracted from evidence fields plus linked DNS/HTTP/SSL altflows +- accepted regexes come from the existing RegexGenerator SQLite store +- co-stimulation and context scores decide whether the cell becomes tolerant, + activates, requests containment, or stores memory +- containment reuses the existing `new_blocking` payload shape +- all T Cell state is stored in its own SQLite DB and log file + +Artifacts: + +- module log: `output/t_cell.log` +- module DB: `/t_cell/t_cell.sqlite` + +See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, +configuration, formulas, and DB schema. From 58e91df1989b7e6ca5f018e9524ded6ed775f84b Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:30:21 +0000 Subject: [PATCH 0108/1100] feat: implement T Cell module for immune-style response and evidence correlation --- modules/t_cell/t_cell.py | 1079 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1079 insertions(+) create mode 100644 modules/t_cell/t_cell.py diff --git a/modules/t_cell/t_cell.py b/modules/t_cell/t_cell.py new file mode 100644 index 0000000000..9e3377c2c7 --- /dev/null +++ b/modules/t_cell/t_cell.py @@ -0,0 +1,1079 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +import os +import re +import time +from dataclasses import dataclass +from urllib.parse import urlparse + +from modules.regex_generator.match_strength import ( + compute_match_strength, + measure_regex_specificity, +) +from slips_files.common.abstracts.imodule import IModule +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.common.slips_utils import utils +from slips_files.core.structures.evidence import ( + EvidenceSignal, + IoCType, + dict_to_evidence, +) + +STATE_MATURE = 0 +STATE_ANTIGEN_RECOGNIZED = 1 +STATE_ANERGIC = 2 +STATE_ACTIVATED = 3 +STATE_EFFECTOR = 4 +STATE_MEMORY = 5 + +STATE_INFO = { + STATE_MATURE: {"label": "0 - mature", "color": "\033[36m"}, + STATE_ANTIGEN_RECOGNIZED: { + "label": "1 - antigen-recognized", + "color": "\033[33m", + }, + STATE_ANERGIC: {"label": "2 - anergic", "color": "\033[34m"}, + STATE_ACTIVATED: {"label": "3 - activated", "color": "\033[35m"}, + STATE_EFFECTOR: {"label": "4 - effector", "color": "\033[31m"}, + STATE_MEMORY: {"label": "5 - memory", "color": "\033[32m"}, +} +COLOR_RESET = "\033[0m" +SUPPORTED_REGEX_TYPES = ( + "dns_domain", + "uri", + "filename", + "tls_sni", + "certificate_cn", +) +DEFAULT_COSTIM_WEIGHTS = { + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, +} + + +@dataclass(frozen=True) +class AntigenCandidate: + regex_type: str + value: str + + def as_dict(self) -> dict: + return {"regex_type": self.regex_type, "value": self.value} + + +@dataclass(frozen=True) +class RegexMatch: + regex_type: str + value: str + regex_hash: str + regex: str + created_at: float + specificity: float + + def as_dict(self) -> dict: + return { + "regex_type": self.regex_type, + "value": self.value, + "regex_hash": self.regex_hash, + "regex": self.regex, + "created_at": self.created_at, + "specificity": self.specificity, + } + + +class TCell(IModule): + name = "T Cell" + description = ( + "Immune-style responder that correlates PAMP evidence with regex " + "matches and escalates to blocking or memory." + ) + authors = ["OpenAI Codex"] + + def init(self): + self.c_evidence = self.db.subscribe("evidence_added") + self.channels = {"evidence_added": self.c_evidence} + self.enabled = False + self.create_log_file = True + self.log_colors = True + self.log_file_path = os.path.join(self.output_dir, "t_cell.log") + self.storage = None + self.observation_retention_seconds = 604800 + self.anergy_ttl_seconds = 21600 + self.related_lookback_seconds = 3600 + self.related_pamps_saturation = 5.0 + self.danger_saturation = 2.5 + self.co_stimulation_threshold = 0.65 + self.co_stimulation_weights = DEFAULT_COSTIM_WEIGHTS.copy() + self.novelty_window_seconds = 86400 + self.context_recent_window_seconds = 1800 + self.effector_threshold = 0.70 + self.effector_min_related_count = 4 + self.effector_cooldown_seconds = 1800 + self.memory_threshold = 0.60 + self.memory_trend_ratio_max = 0.60 + self.memory_min_related_count = 3 + self.simulate_effector_without_blocking = True + self.read_configuration() + + def read_configuration(self): + conf = self.conf if hasattr(self.conf, "t_cell_enabled") else ConfigParser() + self.enabled = conf.t_cell_enabled() + self.create_log_file = conf.t_cell_create_log_file() + self.log_colors = conf.t_cell_log_colors() + self.observation_retention_seconds = ( + conf.t_cell_observation_retention_seconds() + ) + self.anergy_ttl_seconds = conf.t_cell_anergy_ttl_seconds() + self.related_lookback_seconds = conf.t_cell_related_lookback_seconds() + self.related_pamps_saturation = conf.t_cell_related_pamps_saturation() + self.danger_saturation = conf.t_cell_danger_saturation() + self.co_stimulation_threshold = conf.t_cell_co_stimulation_threshold() + self.co_stimulation_weights = self._normalize_weights( + conf.t_cell_co_stimulation_weights() + ) + self.novelty_window_seconds = conf.t_cell_novelty_window_seconds() + self.context_recent_window_seconds = ( + conf.t_cell_context_recent_window_seconds() + ) + self.effector_threshold = conf.t_cell_effector_threshold() + self.effector_min_related_count = ( + conf.t_cell_effector_min_related_count() + ) + self.effector_cooldown_seconds = ( + conf.t_cell_effector_cooldown_seconds() + ) + self.memory_threshold = conf.t_cell_memory_threshold() + self.memory_trend_ratio_max = conf.t_cell_memory_trend_ratio_max() + self.memory_min_related_count = conf.t_cell_memory_min_related_count() + self.simulate_effector_without_blocking = ( + conf.t_cell_simulate_effector_without_blocking() + ) + + def pre_main(self): + utils.drop_root_privs_permanently() + if not self.enabled: + self.print("T Cell module disabled in config.", 2, 0) + return True + + self.storage = self.db.get_t_cell_storage() + self._init_log_file() + self._log_detail("T Cell module ready.") + return False + + def shutdown_gracefully(self): + return True + + def main(self): + if msg := self.get_msg("evidence_added"): + self._process_evidence_message(msg) + return False + + @staticmethod + def _normalize_weights(weights: dict) -> dict: + sanitized = {} + for key, default_value in DEFAULT_COSTIM_WEIGHTS.items(): + raw_value = weights.get(key, default_value) + try: + raw_value = float(raw_value) + except (TypeError, ValueError): + raw_value = default_value + sanitized[key] = max(0.0, raw_value) + + total = sum(sanitized.values()) + if total <= 0: + total = sum(DEFAULT_COSTIM_WEIGHTS.values()) + sanitized = DEFAULT_COSTIM_WEIGHTS.copy() + return {key: value / total for key, value in sanitized.items()} + + def _process_evidence_message(self, message: dict): + try: + raw_evidence = json.loads(message["data"]) + evidence = dict_to_evidence(raw_evidence) + except Exception: + self.print_traceback() + return + + now = time.time() + antigens = self._extract_antigen_candidates(evidence) + observation_id = self.storage.insert_observation( + { + "evidence_id": evidence.id, + "evidence_type": str(evidence.evidence_type), + "evidence_signal": str(evidence.evidence_signal), + "profile_ip": evidence.profile.ip, + "timewindow_number": evidence.timewindow.number, + "timestamp": evidence.timestamp, + "observed_at": now, + "confidence": evidence.confidence, + "threat_level": str(evidence.threat_level), + "threat_level_value": float(evidence.threat_level.value), + "interface": evidence.interface, + "uids": evidence.uid, + "antigen_count": len(antigens), + "antigens": [candidate.as_dict() for candidate in antigens], + "matched_regexes": [], + "raw_evidence": raw_evidence, + } + ) + matched_regexes = [] + + if evidence.evidence_signal != EvidenceSignal.PAMP: + self._log_event( + action="ignored_non_pamp", + state=None, + evidence=evidence, + details=f"signal={evidence.evidence_signal}", + ) + self._prune_observations(now) + return + + if not antigens: + self._log_event( + action="no_antigen_extracted", + state=None, + evidence=evidence, + ) + self._prune_observations(now) + return + + for candidate in antigens: + match = self._process_candidate( + evidence, observation_id, candidate, now + ) + if match: + matched_regexes.append(match.as_dict()) + + self.storage.update_observation_matches(observation_id, matched_regexes) + self._prune_observations(now) + + def _process_candidate( + self, + evidence, + observation_id: int, + candidate: AntigenCandidate, + now: float, + ) -> RegexMatch | None: + cell = self._get_or_create_cell( + evidence.profile.ip, candidate.regex_type, candidate.value, now + ) + + if ( + cell["state"] == STATE_ANERGIC + and cell.get("anergic_until") + and now < cell["anergic_until"] + ): + self._log_event( + action="anergy_suppressed", + state=cell["state"], + evidence=evidence, + cell=cell, + details=f"until={cell['anergic_until']:.3f}", + ) + return None + + if ( + cell["state"] == STATE_ANERGIC + and cell.get("anergic_until") + and now >= cell["anergic_until"] + ): + cell = self._transition_cell( + cell=cell, + to_state=STATE_MATURE, + reason="anergy_expired", + evidence=evidence, + observation_id=observation_id, + now=now, + scores={"anergic_until": None}, + extra_updates={"anergic_until": None}, + ) + + match = self._find_best_regex_match(candidate) + if not match: + if cell["state"] == STATE_MATURE: + cell = self._transition_cell( + cell=cell, + to_state=STATE_ANERGIC, + reason="no_regex_match", + evidence=evidence, + observation_id=observation_id, + now=now, + scores={"anergic_until": now + self.anergy_ttl_seconds}, + extra_updates={"anergic_until": now + self.anergy_ttl_seconds}, + ) + else: + self._update_cell( + cell, + now, + last_observation_id=observation_id, + last_evidence_id=evidence.id, + context={ + "reason": "no_regex_match_after_activation", + "observation_id": observation_id, + }, + ) + self._log_event( + action="no_regex_match", + state=cell["state"], + evidence=evidence, + cell=cell, + ) + return None + + match_updates = { + "matched_regex_hash": match.regex_hash, + "matched_regex": match.regex, + "matched_value": match.value, + "last_observation_id": observation_id, + "last_evidence_id": evidence.id, + "anergic_until": None, + } + if cell["state"] == STATE_MATURE: + cell = self._transition_cell( + cell=cell, + to_state=STATE_ANTIGEN_RECOGNIZED, + reason="antigen_recognized", + evidence=evidence, + observation_id=observation_id, + now=now, + match=match, + scores={"regex_specificity": match.specificity}, + extra_updates=match_updates, + ) + else: + cell = self._update_cell( + cell, + now, + **match_updates, + ) + + co_stimulation = self._compute_co_stimulation( + evidence.profile.ip, + observation_id, + candidate, + match, + now, + ) + cell = self._update_cell( + cell, + now, + last_co_stimulation=co_stimulation["value"], + context={"co_stimulation": co_stimulation}, + ) + + if cell["state"] < STATE_ACTIVATED: + if co_stimulation["value"] >= self.co_stimulation_threshold: + cell = self._transition_cell( + cell=cell, + to_state=STATE_ACTIVATED, + reason="co_stimulation_threshold_met", + evidence=evidence, + observation_id=observation_id, + now=now, + match=match, + scores=co_stimulation, + ) + else: + self._log_event( + action="co_stimulation_pending", + state=cell["state"], + evidence=evidence, + cell=cell, + match=match, + metrics={"co_stimulation": co_stimulation["value"]}, + ) + return match + + context = self._compute_context_signals( + evidence.profile.ip, + observation_id, + candidate, + match, + now, + ) + cell = self._update_cell( + cell, + now, + last_effector_score=context["effector_score"], + last_memory_score=context["memory_score"], + context={"co_stimulation": co_stimulation, "context": context}, + ) + + if context["effector"]: + if cell["state"] != STATE_EFFECTOR: + cell = self._transition_cell( + cell=cell, + to_state=STATE_EFFECTOR, + reason="context_effector", + evidence=evidence, + observation_id=observation_id, + now=now, + match=match, + scores=context, + ) + self._apply_effector(cell, evidence, match, context, now) + return match + + if context["memory"]: + if cell["state"] != STATE_MEMORY: + cell = self._transition_cell( + cell=cell, + to_state=STATE_MEMORY, + reason="context_memory", + evidence=evidence, + observation_id=observation_id, + now=now, + match=match, + scores=context, + ) + self._store_memory(cell, match, context, now) + self._log_event( + action="memory_stored", + state=STATE_MEMORY, + evidence=evidence, + cell=cell, + match=match, + metrics={"memory_score": context["memory_score"]}, + ) + return match + + self._log_event( + action="context_hold", + state=cell["state"], + evidence=evidence, + cell=cell, + match=match, + metrics={ + "effector_score": context["effector_score"], + "memory_score": context["memory_score"], + }, + ) + return match + + def _get_or_create_cell( + self, profile_ip: str, regex_type: str, antigen_value: str, now: float + ) -> dict: + cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) + cell = self.storage.get_cell(cell_key) + if cell: + return cell + + return { + "cell_key": cell_key, + "profile_ip": profile_ip, + "regex_type": regex_type, + "antigen_value": antigen_value, + "state": STATE_MATURE, + "state_name": STATE_INFO[STATE_MATURE]["label"], + "matched_regex_hash": None, + "matched_regex": None, + "matched_value": None, + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": None, + "last_evidence_id": None, + "last_transition_at": None, + "last_co_stimulation": None, + "last_effector_score": None, + "last_memory_score": None, + "context": {}, + "created_at": now, + "updated_at": now, + } + + def _transition_cell( + self, + cell: dict, + to_state: int, + reason: str, + evidence, + observation_id: int, + now: float, + match: RegexMatch | None = None, + scores: dict | None = None, + extra_updates: dict | None = None, + ) -> dict: + from_state = cell["state"] + updates = { + "state": to_state, + "state_name": STATE_INFO[to_state]["label"], + "last_observation_id": observation_id, + "last_evidence_id": evidence.id, + "last_transition_at": now, + } + if match: + updates.update( + { + "matched_regex_hash": match.regex_hash, + "matched_regex": match.regex, + "matched_value": match.value, + } + ) + if extra_updates: + updates.update(extra_updates) + + cell = self._update_cell(cell, now, **updates) + self.storage.insert_transition( + { + "cell_key": cell["cell_key"], + "profile_ip": cell["profile_ip"], + "regex_type": cell["regex_type"], + "antigen_value": cell["antigen_value"], + "evidence_id": evidence.id, + "observation_id": observation_id, + "from_state": from_state, + "to_state": to_state, + "reason": reason, + "matched_regex_hash": cell.get("matched_regex_hash"), + "matched_regex": cell.get("matched_regex"), + "matched_value": cell.get("matched_value"), + "scores": scores or {}, + "created_at": now, + } + ) + self._log_event( + action=reason, + state=to_state, + evidence=evidence, + cell=cell, + match=match, + metrics=scores, + ) + return cell + + def _update_cell(self, cell: dict, now: float, **updates) -> dict: + cell.update(updates) + cell["updated_at"] = now + self.storage.upsert_cell(cell) + return cell + + def _compute_co_stimulation( + self, + profile_ip: str, + observation_id: int, + candidate: AntigenCandidate, + match: RegexMatch, + now: float, + ) -> dict: + observations = self.storage.get_recent_observations( + profile_ip, + now - self.related_lookback_seconds, + evidence_signal="PAMP", + ) + current_observation = self.storage.get_observation(observation_id) or {} + confidence = float(current_observation.get("confidence", 0.0)) + related_pamp_count = self._count_related_observations( + observations, + candidate, + match.regex_hash, + exclude_observation_id=observation_id, + ) + related_pamp_score = self._clamp01( + related_pamp_count / self.related_pamps_saturation + ) + profile_danger_score = self._normalize_danger( + self._sum_danger(observations) + ) + value = ( + self.co_stimulation_weights["confidence"] * confidence + + self.co_stimulation_weights["related_pamps"] * related_pamp_score + + self.co_stimulation_weights["danger"] * profile_danger_score + ) + return { + "value": value, + "confidence": confidence, + "related_pamp_count": related_pamp_count, + "related_pamp_score": related_pamp_score, + "profile_danger_score": profile_danger_score, + "threshold": self.co_stimulation_threshold, + } + + def _compute_context_signals( + self, + profile_ip: str, + observation_id: int, + candidate: AntigenCandidate, + match: RegexMatch, + now: float, + ) -> dict: + recent_start = now - self.context_recent_window_seconds + previous_start = now - (2 * self.context_recent_window_seconds) + + recent_observations = self.storage.get_recent_observations( + profile_ip, + recent_start, + evidence_signal="PAMP", + ) + previous_observations = self.storage.get_recent_observations( + profile_ip, + previous_start, + until_ts=recent_start, + evidence_signal="PAMP", + ) + recent_related_count = self._count_related_observations( + recent_observations, + candidate, + match.regex_hash, + exclude_observation_id=observation_id, + ) + recent_related_score = self._clamp01( + recent_related_count / self.related_pamps_saturation + ) + recent_pressure = self._normalize_danger( + self._sum_danger(recent_observations) + ) + previous_pressure = self._normalize_danger( + self._sum_danger(previous_observations) + ) + trend_ratio = recent_pressure / max(previous_pressure, 0.01) + novelty_score = ( + 1.0 + if self._is_novel_regex( + profile_ip, match, observation_id, now + ) + else 0.0 + ) + effector_score = ( + (0.45 * recent_pressure) + + (0.25 * recent_related_score) + + (0.30 * novelty_score) + ) + decrease_score = self._clamp01(1.0 - trend_ratio) + familiarity_score = 1.0 - novelty_score + stability_score = self._clamp01( + recent_related_count / self.memory_min_related_count + ) + memory_score = ( + (0.60 * decrease_score) + + (0.25 * familiarity_score) + + (0.15 * stability_score) + ) + effector = ( + novelty_score > 0 + and recent_related_count >= self.effector_min_related_count + and effector_score >= self.effector_threshold + ) + memory = ( + familiarity_score > 0 + and recent_related_count >= self.memory_min_related_count + and trend_ratio <= self.memory_trend_ratio_max + and memory_score >= self.memory_threshold + ) + return { + "novelty_score": novelty_score, + "recent_pressure": recent_pressure, + "previous_pressure": previous_pressure, + "trend_ratio": trend_ratio, + "recent_related_count": recent_related_count, + "recent_related_score": recent_related_score, + "effector_score": effector_score, + "memory_score": memory_score, + "decrease_score": decrease_score, + "familiarity_score": familiarity_score, + "stability_score": stability_score, + "effector_threshold": self.effector_threshold, + "memory_threshold": self.memory_threshold, + "effector": effector, + "memory": memory, + } + + def _is_novel_regex( + self, + profile_ip: str, + match: RegexMatch, + observation_id: int, + now: float, + ) -> bool: + if self.storage.has_memory_for_regex(match.regex_hash): + return False + return not self.storage.has_recent_regex_activity( + profile_ip, + match.regex_hash, + now - self.novelty_window_seconds, + exclude_observation_id=observation_id, + ) + + def _apply_effector( + self, + cell: dict, + evidence, + match: RegexMatch, + context: dict, + now: float, + ): + cooldown_until = cell.get("effector_cooldown_until") or 0 + if now < cooldown_until: + self._log_event( + action="effector_cooldown", + state=STATE_EFFECTOR, + evidence=evidence, + cell=cell, + match=match, + metrics={"cooldown_until": cooldown_until}, + ) + return + + target_ip = evidence.profile.ip + blocking_data = { + "ip": target_ip, + "block": True, + "tw": evidence.timewindow.number, + "interface": utils.get_interface_of_ip(target_ip, self.db, self.args), + } + next_cooldown = now + self.effector_cooldown_seconds + self._update_cell( + cell, + now, + effector_cooldown_until=next_cooldown, + context={"context": context, "effector_payload": blocking_data}, + ) + + if self._blocking_modules_available(): + self.db.publish("new_blocking", json.dumps(blocking_data)) + self._log_event( + action="effector_published", + state=STATE_EFFECTOR, + evidence=evidence, + cell=cell, + match=match, + metrics={"effector_score": context["effector_score"]}, + ) + return + + if self.simulate_effector_without_blocking: + self._log_event( + action="effector_simulated", + state=STATE_EFFECTOR, + evidence=evidence, + cell=cell, + match=match, + details=json.dumps(blocking_data, sort_keys=True), + metrics={"effector_score": context["effector_score"]}, + ) + return + + self._log_event( + action="effector_unavailable", + state=STATE_EFFECTOR, + evidence=evidence, + cell=cell, + match=match, + metrics={"effector_score": context["effector_score"]}, + ) + + def _store_memory( + self, cell: dict, match: RegexMatch, context: dict, now: float + ): + self.storage.upsert_memory( + { + "cell_key": cell["cell_key"], + "profile_ip": cell["profile_ip"], + "regex_type": cell["regex_type"], + "antigen_value": cell["antigen_value"], + "regex_hash": match.regex_hash, + "regex": match.regex, + "matched_value": match.value, + "context": context, + "created_at": now, + "updated_at": now, + } + ) + + def _blocking_modules_available(self) -> bool: + blocking_pid = self.db.get_pid_of("Blocking") + arp_pid = self.db.get_pid_of("ARP Poisoner") + return self._pid_is_running(blocking_pid) or self._pid_is_running( + arp_pid + ) + + @staticmethod + def _pid_is_running(pid) -> bool: + if isinstance(pid, int): + return pid > 0 + if isinstance(pid, str): + return pid.isdigit() and int(pid) > 0 + return False + + def _find_best_regex_match( + self, candidate: AntigenCandidate + ) -> RegexMatch | None: + regex_records = self.db.get_generated_regexes( + regex_type=candidate.regex_type, status="accepted" + ) + best_match = None + best_key = None + for record in regex_records or []: + regex_text = str(record.get("regex", "")) + if not regex_text: + continue + try: + compiled_regex = re.compile(regex_text) + if not compiled_regex.search(candidate.value): + continue + except re.error: + continue + + specificity_features = measure_regex_specificity(regex_text) + specificity = float( + specificity_features.get("specificity_ratio", 0.0) + ) + wildcard_penalty = float( + specificity_features.get("wildcard_penalty", 1.0) + ) + match_strength = compute_match_strength( + compiled_regex, + candidate.value, + regex_features=specificity_features, + ) + created_at = float(record.get("created_at") or 0.0) + sort_key = ( + match_strength, + specificity, + -wildcard_penalty, + created_at, + ) + if best_key is not None and sort_key <= best_key: + continue + + best_key = sort_key + best_match = RegexMatch( + regex_type=candidate.regex_type, + value=candidate.value, + regex_hash=str(record.get("regex_hash", "")), + regex=regex_text, + created_at=created_at, + specificity=specificity, + ) + return best_match + + def _extract_antigen_candidates(self, evidence) -> list[AntigenCandidate]: + candidates = {} + + for entity in (evidence.attacker, evidence.victim): + self._extract_from_entity(entity, candidates) + + for uid in evidence.uid: + flow = self._unwrap_flow_record(self.db.get_altflow_from_uid(uid)) + if not flow: + continue + + flow_type = str( + flow.get("flow_type") or flow.get("type_") or "" + ).lower() + if flow_type == "dns" or "query" in flow: + self._add_candidate( + candidates, "dns_domain", self._normalize_domain(flow.get("query")) + ) + if flow_type == "http" or "uri" in flow or "host" in flow: + self._add_candidate( + candidates, "dns_domain", self._normalize_domain(flow.get("host")) + ) + uri = self._normalize_uri(flow.get("uri")) + self._add_candidate(candidates, "uri", uri) + self._add_candidate( + candidates, "filename", self._extract_filename_from_uri(uri) + ) + if flow_type == "ssl" or "server_name" in flow or "subject" in flow: + self._add_candidate( + candidates, + "tls_sni", + self._normalize_domain(flow.get("server_name")), + ) + self._add_candidate( + candidates, + "certificate_cn", + self._extract_cn(flow.get("subject")), + ) + + return [ + AntigenCandidate(regex_type=regex_type, value=value) + for regex_type, value in sorted(candidates.keys()) + ] + + def _extract_from_entity(self, entity, candidates: dict): + if not entity: + return + + if entity.ioc_type == IoCType.DOMAIN: + self._add_candidate( + candidates, "dns_domain", self._normalize_domain(entity.value) + ) + elif entity.ioc_type == IoCType.URL: + parsed = urlparse(str(entity.value or "").strip()) + self._add_candidate( + candidates, "dns_domain", self._normalize_domain(parsed.hostname) + ) + uri = self._normalize_uri(entity.value) + self._add_candidate(candidates, "uri", uri) + self._add_candidate( + candidates, "filename", self._extract_filename_from_uri(uri) + ) + + self._add_candidate( + candidates, "tls_sni", self._normalize_domain(getattr(entity, "SNI", "")) + ) + + def _count_related_observations( + self, + observations: list[dict], + candidate: AntigenCandidate, + regex_hash: str, + exclude_observation_id: int, + ) -> int: + count = 0 + for observation in observations: + if observation["id"] == exclude_observation_id: + continue + if self._is_related_observation(observation, candidate, regex_hash): + count += 1 + return count + + @staticmethod + def _is_related_observation( + observation: dict, candidate: AntigenCandidate, regex_hash: str + ) -> bool: + for antigen in observation.get("antigens", []): + if ( + antigen.get("regex_type") == candidate.regex_type + and antigen.get("value") == candidate.value + ): + return True + for match in observation.get("matched_regexes", []): + if regex_hash and match.get("regex_hash") == regex_hash: + return True + return False + + @staticmethod + def _sum_danger(observations: list[dict]) -> float: + return sum( + float(obs.get("threat_level_value", 0.0)) + * float(obs.get("confidence", 0.0)) + for obs in observations + ) + + def _normalize_danger(self, raw_value: float) -> float: + return self._clamp01(raw_value / self.danger_saturation) + + @staticmethod + def _clamp01(value: float) -> float: + return max(0.0, min(1.0, float(value))) + + @staticmethod + def _make_cell_key(profile_ip: str, regex_type: str, antigen_value: str) -> str: + return f"{profile_ip}|{regex_type}|{antigen_value}" + + @staticmethod + def _unwrap_flow_record(flow_record) -> dict: + if not isinstance(flow_record, dict): + return {} + if isinstance(flow_record.get("flow"), dict): + flow = dict(flow_record["flow"]) + flow["flow_type"] = flow_record.get("flow_type") or flow.get( + "flow_type" + ) + return flow + return dict(flow_record) + + @staticmethod + def _add_candidate(candidates: dict, regex_type: str, value: str): + normalized = str(value or "").strip() + if regex_type not in SUPPORTED_REGEX_TYPES or not normalized: + return + candidates[(regex_type, normalized)] = True + + @staticmethod + def _normalize_domain(value: str) -> str: + domain = str(value or "").strip().rstrip(".").lower() + if not domain or not utils.is_valid_domain(domain): + return "" + return domain + + @staticmethod + def _normalize_uri(value: str) -> str: + raw_value = str(value or "").strip() + if not raw_value: + return "" + parsed = urlparse(raw_value) + if parsed.scheme or parsed.netloc: + uri = parsed.path or "/" + if parsed.query: + uri = f"{uri}?{parsed.query}" + return uri + return raw_value + + @staticmethod + def _extract_cn(subject: str) -> str: + match = re.search(r"(?:^|,)CN=([^,]+)", str(subject or "")) + if not match: + return "" + return match.group(1).strip() + + @staticmethod + def _extract_filename_from_uri(uri: str) -> str: + value = str(uri or "").strip() + if not value: + return "" + parsed = urlparse(value) + path = parsed.path or value + filename = path.rsplit("/", 1)[-1].strip() + if not filename or "." not in filename: + return "" + return filename + + def _prune_observations(self, now: float): + cutoff = now - self.observation_retention_seconds + self.storage.prune_observations(cutoff) + + def _init_log_file(self): + if not self.create_log_file: + return + os.makedirs(self.output_dir, exist_ok=True) + with open(self.log_file_path, "w", encoding="utf-8") as log_file: + log_file.write("") + + def _colorize_state(self, state: int) -> str: + label = STATE_INFO[state]["label"] + if not self.log_colors: + return label + return f"{STATE_INFO[state]['color']}{label}{COLOR_RESET}" + + def _log_event( + self, + action: str, + evidence, + state: int | None, + cell: dict | None = None, + match: RegexMatch | None = None, + details: str | None = None, + metrics: dict | None = None, + ): + parts = [ + utils.convert_ts_format(time.time(), utils.alerts_format), + action, + ] + if state is not None: + parts.append(self._colorize_state(state)) + if evidence: + parts.append(f"evidence={evidence.evidence_type.name}") + parts.append(f"eid={evidence.id}") + parts.append(f"profile={evidence.profile.ip}") + if cell: + parts.append(f"cell={cell['cell_key']}") + if match: + parts.append(f"regex={match.regex_hash}") + parts.append(f"value={match.value}") + if metrics: + metric_text = ",".join( + f"{key}={value:.3f}" if isinstance(value, float) else f"{key}={value}" + for key, value in metrics.items() + ) + parts.append(metric_text) + if details: + parts.append(details) + self._log_detail(" | ".join(parts)) + + def _log_detail(self, text: str): + if not self.create_log_file: + return + with open(self.log_file_path, "a", encoding="utf-8") as log_file: + log_file.write(f"{text}\n") From fc5beb322968deae8d9fa400fde4d934e974506f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:30:30 +0000 Subject: [PATCH 0109/1100] feat: add T Cell configuration methods to ConfigParser for enhanced functionality --- slips_files/common/parsers/config_parser.py | 200 ++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index 0884dc9cfe..b00e078189 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -941,6 +941,203 @@ def regex_generator_seed_benign_samples(self) -> bool: return value return str(value).strip().lower() in ("true", "1", "yes", "on") + def t_cell_enabled(self) -> bool: + value = self.read_configuration("t_cell", "enabled", False) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") + + def t_cell_create_log_file(self) -> bool: + value = self.read_configuration("t_cell", "create_log_file", True) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") + + def t_cell_log_colors(self) -> bool: + value = self.read_configuration("t_cell", "log_colors", True) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") + + def t_cell_store_dir(self) -> str: + value = self.read_configuration("t_cell", "store_dir", "output/t_cell") + if not isinstance(value, str) or not value.strip(): + return "output/t_cell" + return value.strip() + + def t_cell_persistent_store_dir(self) -> str: + value = self.read_configuration( + "t_cell", "persistent_store_dir", "" + ) + if not isinstance(value, str) or not value.strip(): + return "" + return value.strip() + + def t_cell_observation_retention_seconds(self) -> int: + value = self.read_configuration( + "t_cell", "observation_retention_seconds", 604800 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 604800 + return max(0, value) + + def t_cell_anergy_ttl_seconds(self) -> int: + value = self.read_configuration("t_cell", "anergy_ttl_seconds", 21600) + try: + value = int(value) + except (TypeError, ValueError): + value = 21600 + return max(0, value) + + def t_cell_related_lookback_seconds(self) -> int: + value = self.read_configuration( + "t_cell", "related_lookback_seconds", 3600 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 3600 + return max(1, value) + + def t_cell_related_pamps_saturation(self) -> float: + value = self.read_configuration( + "t_cell", "related_pamps_saturation", 5 + ) + try: + value = float(value) + except (TypeError, ValueError): + value = 5.0 + return max(0.01, value) + + def t_cell_danger_saturation(self) -> float: + value = self.read_configuration("t_cell", "danger_saturation", 2.5) + try: + value = float(value) + except (TypeError, ValueError): + value = 2.5 + return max(0.01, value) + + def t_cell_co_stimulation_threshold(self) -> float: + value = self.read_configuration( + "t_cell", "co_stimulation_threshold", 0.65 + ) + try: + value = float(value) + except (TypeError, ValueError): + value = 0.65 + return max(0.0, min(1.0, value)) + + def t_cell_co_stimulation_weights(self) -> dict: + default_weights = { + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, + } + value = self.read_configuration( + "t_cell", "co_stimulation_weights", default_weights + ) + if not isinstance(value, dict): + return default_weights + + sanitized_weights = {} + for weight_name, default_weight in default_weights.items(): + raw_weight = value.get(weight_name, default_weight) + try: + raw_weight = float(raw_weight) + except (TypeError, ValueError): + raw_weight = default_weight + sanitized_weights[weight_name] = max(0.0, raw_weight) + + if not any(sanitized_weights.values()): + return default_weights + return sanitized_weights + + def t_cell_novelty_window_seconds(self) -> int: + value = self.read_configuration( + "t_cell", "novelty_window_seconds", 86400 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 86400 + return max(1, value) + + def t_cell_context_recent_window_seconds(self) -> int: + value = self.read_configuration( + "t_cell", "context_recent_window_seconds", 1800 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 1800 + return max(1, value) + + def t_cell_effector_threshold(self) -> float: + value = self.read_configuration("t_cell", "effector_threshold", 0.70) + try: + value = float(value) + except (TypeError, ValueError): + value = 0.70 + return max(0.0, min(1.0, value)) + + def t_cell_effector_min_related_count(self) -> int: + value = self.read_configuration( + "t_cell", "effector_min_related_count", 4 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 4 + return max(1, value) + + def t_cell_effector_cooldown_seconds(self) -> int: + value = self.read_configuration( + "t_cell", "effector_cooldown_seconds", 1800 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 1800 + return max(0, value) + + def t_cell_memory_threshold(self) -> float: + value = self.read_configuration("t_cell", "memory_threshold", 0.60) + try: + value = float(value) + except (TypeError, ValueError): + value = 0.60 + return max(0.0, min(1.0, value)) + + def t_cell_memory_trend_ratio_max(self) -> float: + value = self.read_configuration( + "t_cell", "memory_trend_ratio_max", 0.60 + ) + try: + value = float(value) + except (TypeError, ValueError): + value = 0.60 + return max(0.0, value) + + def t_cell_memory_min_related_count(self) -> int: + value = self.read_configuration( + "t_cell", "memory_min_related_count", 3 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 3 + return max(1, value) + + def t_cell_simulate_effector_without_blocking(self) -> bool: + value = self.read_configuration( + "t_cell", "simulate_effector_without_blocking", True + ) + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "1", "yes", "on") + def analysis_direction(self): """ Controls which traffic flows are processed and analyzed by SLIPS. @@ -1154,6 +1351,9 @@ def get_disabled_modules(self, input_type: str) -> list: if not self.regex_generator_enabled(): to_ignore.append("regex_generator") + if not self.t_cell_enabled(): + to_ignore.append("t_cell") + return to_ignore def get_cpu_profiler_enable(self): From 4d899f74869ae4486d6f799c98a8cff8f221f5c6 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:30:40 +0000 Subject: [PATCH 0110/1100] feat: add T Cell storage management methods to DBManager for improved data handling --- slips_files/core/database/database_manager.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/slips_files/core/database/database_manager.py b/slips_files/core/database/database_manager.py index fe4a85eca8..14cd29af6d 100644 --- a/slips_files/core/database/database_manager.py +++ b/slips_files/core/database/database_manager.py @@ -48,6 +48,7 @@ def __init__( self.logger = logger self.printer = Printer(self.logger, self.name) self.regex_generator_storage = None + self.t_cell_storage = None # only the main process should ever flush the Redis DB. to avoid # children overwriting values set at the very start of slips if os.getpid() != main_pid: @@ -1122,6 +1123,23 @@ def get_generated_regexes_count(self, *args, **kwargs): *args, **kwargs ) + def _get_t_cell_storage(self): + if self.t_cell_storage is None: + from slips_files.core.database.sqlite_db.t_cell_db import ( + TCellStorage, + ) + + self.t_cell_storage = TCellStorage( + self.logger, + self.conf, + self.output_dir, + self.main_pid, + ) + return self.t_cell_storage + + def get_t_cell_storage(self): + return self._get_t_cell_storage() + def get_icmp_attack_info_to_single_host(self, *args, **kwargs): return self.rdb.get_icmp_attack_info_to_single_host(*args, **kwargs) @@ -1218,6 +1236,8 @@ def close_sqlite(self, *args, **kwargs): self.sqlite.close(*args, **kwargs) if self.regex_generator_storage: self.regex_generator_storage.close() + if self.t_cell_storage: + self.t_cell_storage.close() def close_all_dbs(self): self.rdb.r.close() From d040fa8de46cf33cfa0328cab1d5d0c857d44200 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:30:47 +0000 Subject: [PATCH 0111/1100] feat: implement TCellSQLiteDB and TCellStorage for database management of T Cell observations and transitions --- .../core/database/sqlite_db/t_cell_db.py | 604 ++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 slips_files/core/database/sqlite_db/t_cell_db.py diff --git a/slips_files/core/database/sqlite_db/t_cell_db.py b/slips_files/core/database/sqlite_db/t_cell_db.py new file mode 100644 index 0000000000..bfd40367e1 --- /dev/null +++ b/slips_files/core/database/sqlite_db/t_cell_db.py @@ -0,0 +1,604 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +import os +from pathlib import Path +from time import time + +from slips_files.common.abstracts.isqlite import ISQLite +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.common.printer import Printer +from slips_files.core.output import Output + +DEFAULT_T_CELL_STORE_DIR = "output/t_cell" + + +class _BaseTCellSQLiteDB(ISQLite): + name = "BaseTCellSQLiteDB" + + def __init__(self, logger: Output, db_path: str, main_pid: int): + self.printer = Printer(logger, self.name) + self.db_path = db_path + self._init_db_file() + super().__init__(self.name.lower(), main_pid, db_path) + self.init_tables() + + def _init_db_file(self): + db_file = Path(self.db_path) + db_file.parent.mkdir(parents=True, exist_ok=True) + if not db_file.exists(): + db_file.touch() + os.chmod(db_file, 0o777) + + @staticmethod + def _loads(value: str, fallback): + try: + return json.loads(value) + except (TypeError, ValueError): + return fallback + + +class TCellSQLiteDB(_BaseTCellSQLiteDB): + name = "TCellSQLiteDB" + + def init_tables(self): + self.create_table( + "observations", + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "evidence_id TEXT NOT NULL, " + "evidence_type TEXT NOT NULL, " + "evidence_signal TEXT NOT NULL, " + "profile_ip TEXT NOT NULL, " + "timewindow_number INTEGER NOT NULL, " + "timestamp TEXT NOT NULL, " + "observed_at REAL NOT NULL, " + "confidence REAL NOT NULL, " + "threat_level TEXT NOT NULL, " + "threat_level_value REAL NOT NULL, " + "interface TEXT, " + "uid_json TEXT NOT NULL, " + "antigen_count INTEGER NOT NULL, " + "antigens_json TEXT NOT NULL, " + "matched_regexes_json TEXT NOT NULL, " + "raw_evidence_json TEXT NOT NULL", + ) + self.create_table( + "cells", + "cell_key TEXT PRIMARY KEY, " + "profile_ip TEXT NOT NULL, " + "regex_type TEXT NOT NULL, " + "antigen_value TEXT NOT NULL, " + "state INTEGER NOT NULL, " + "state_name TEXT NOT NULL, " + "matched_regex_hash TEXT, " + "matched_regex TEXT, " + "matched_value TEXT, " + "anergic_until REAL, " + "effector_cooldown_until REAL, " + "last_observation_id INTEGER, " + "last_evidence_id TEXT, " + "last_transition_at REAL, " + "last_co_stimulation REAL, " + "last_effector_score REAL, " + "last_memory_score REAL, " + "context_json TEXT NOT NULL, " + "created_at REAL NOT NULL, " + "updated_at REAL NOT NULL", + ) + self.create_table( + "transitions", + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "cell_key TEXT NOT NULL, " + "profile_ip TEXT NOT NULL, " + "regex_type TEXT NOT NULL, " + "antigen_value TEXT NOT NULL, " + "evidence_id TEXT NOT NULL, " + "observation_id INTEGER, " + "from_state INTEGER, " + "to_state INTEGER, " + "reason TEXT NOT NULL, " + "matched_regex_hash TEXT, " + "matched_regex TEXT, " + "matched_value TEXT, " + "scores_json TEXT NOT NULL, " + "created_at REAL NOT NULL", + ) + self.create_table( + "memories", + "cell_key TEXT PRIMARY KEY, " + "profile_ip TEXT NOT NULL, " + "regex_type TEXT NOT NULL, " + "antigen_value TEXT NOT NULL, " + "regex_hash TEXT NOT NULL, " + "regex TEXT NOT NULL, " + "matched_value TEXT NOT NULL, " + "context_json TEXT NOT NULL, " + "created_at REAL NOT NULL, " + "updated_at REAL NOT NULL", + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_tcell_observations_profile_time " + "ON observations (profile_ip, observed_at)" + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_tcell_observations_signal_time " + "ON observations (evidence_signal, observed_at)" + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_tcell_cells_profile_type " + "ON cells (profile_ip, regex_type)" + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_tcell_cells_regex_hash " + "ON cells (matched_regex_hash)" + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_tcell_transitions_cell_time " + "ON transitions (cell_key, created_at)" + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_tcell_transitions_regex_time " + "ON transitions (matched_regex_hash, profile_ip, created_at)" + ) + self.execute( + "CREATE INDEX IF NOT EXISTS idx_tcell_memories_regex_hash " + "ON memories (regex_hash)" + ) + + def insert_observation(self, record: dict) -> int: + cursor = self.execute( + "INSERT INTO observations (" + "evidence_id, evidence_type, evidence_signal, profile_ip, " + "timewindow_number, timestamp, observed_at, confidence, " + "threat_level, threat_level_value, interface, uid_json, " + "antigen_count, antigens_json, matched_regexes_json, raw_evidence_json" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + record["evidence_id"], + record["evidence_type"], + record["evidence_signal"], + record["profile_ip"], + record["timewindow_number"], + record["timestamp"], + record["observed_at"], + record["confidence"], + record["threat_level"], + record["threat_level_value"], + record.get("interface"), + json.dumps(record.get("uids", [])), + int(record.get("antigen_count", 0)), + json.dumps(record.get("antigens", [])), + json.dumps(record.get("matched_regexes", [])), + json.dumps(record.get("raw_evidence", {})), + ), + ) + return cursor.lastrowid if cursor else 0 + + def update_observation_matches( + self, observation_id: int, matched_regexes: list[dict] + ): + self.execute( + "UPDATE observations SET matched_regexes_json = ? WHERE id = ?", + (json.dumps(matched_regexes or []), observation_id), + ) + + @staticmethod + def _row_to_observation(row) -> dict: + return { + "id": row[0], + "evidence_id": row[1], + "evidence_type": row[2], + "evidence_signal": row[3], + "profile_ip": row[4], + "timewindow_number": row[5], + "timestamp": row[6], + "observed_at": row[7], + "confidence": row[8], + "threat_level": row[9], + "threat_level_value": row[10], + "interface": row[11], + "uids": _BaseTCellSQLiteDB._loads(row[12], []), + "antigen_count": row[13], + "antigens": _BaseTCellSQLiteDB._loads(row[14], []), + "matched_regexes": _BaseTCellSQLiteDB._loads(row[15], []), + "raw_evidence": _BaseTCellSQLiteDB._loads(row[16], {}), + } + + def get_observation(self, observation_id: int) -> dict | None: + row = self.select( + "observations", + condition="id = ?", + params=(observation_id,), + limit=1, + ) + if not row: + return None + return self._row_to_observation(row) + + def get_recent_observations( + self, + profile_ip: str, + since_ts: float, + until_ts: float | None = None, + evidence_signal: str | None = None, + ) -> list[dict]: + condition_parts = ["profile_ip = ?", "observed_at >= ?"] + params = [profile_ip, since_ts] + if until_ts is not None: + condition_parts.append("observed_at < ?") + params.append(until_ts) + if evidence_signal: + condition_parts.append("evidence_signal = ?") + params.append(evidence_signal) + + rows = self.select( + "observations", + condition=" AND ".join(condition_parts), + params=tuple(params), + order_by="observed_at DESC, id DESC", + ) + rows = rows or [] + return [self._row_to_observation(row) for row in rows] + + def prune_observations(self, created_before: float): + self.execute( + "DELETE FROM observations WHERE observed_at < ?", (created_before,) + ) + + @staticmethod + def _row_to_cell(row) -> dict: + return { + "cell_key": row[0], + "profile_ip": row[1], + "regex_type": row[2], + "antigen_value": row[3], + "state": row[4], + "state_name": row[5], + "matched_regex_hash": row[6], + "matched_regex": row[7], + "matched_value": row[8], + "anergic_until": row[9], + "effector_cooldown_until": row[10], + "last_observation_id": row[11], + "last_evidence_id": row[12], + "last_transition_at": row[13], + "last_co_stimulation": row[14], + "last_effector_score": row[15], + "last_memory_score": row[16], + "context": _BaseTCellSQLiteDB._loads(row[17], {}), + "created_at": row[18], + "updated_at": row[19], + } + + def get_cell(self, cell_key: str) -> dict | None: + row = self.select( + "cells", + condition="cell_key = ?", + params=(cell_key,), + limit=1, + ) + if not row: + return None + return self._row_to_cell(row) + + def get_all_cells(self) -> list[dict]: + rows = self.select("cells", order_by="updated_at DESC") or [] + return [self._row_to_cell(row) for row in rows] + + def upsert_cell(self, record: dict): + self.execute( + "INSERT OR REPLACE INTO cells (" + "cell_key, profile_ip, regex_type, antigen_value, state, state_name, " + "matched_regex_hash, matched_regex, matched_value, anergic_until, " + "effector_cooldown_until, last_observation_id, last_evidence_id, " + "last_transition_at, last_co_stimulation, last_effector_score, " + "last_memory_score, context_json, created_at, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + record["cell_key"], + record["profile_ip"], + record["regex_type"], + record["antigen_value"], + record["state"], + record["state_name"], + record.get("matched_regex_hash"), + record.get("matched_regex"), + record.get("matched_value"), + record.get("anergic_until"), + record.get("effector_cooldown_until"), + record.get("last_observation_id"), + record.get("last_evidence_id"), + record.get("last_transition_at"), + record.get("last_co_stimulation"), + record.get("last_effector_score"), + record.get("last_memory_score"), + json.dumps(record.get("context", {})), + record["created_at"], + record["updated_at"], + ), + ) + + @staticmethod + def _row_to_transition(row) -> dict: + return { + "id": row[0], + "cell_key": row[1], + "profile_ip": row[2], + "regex_type": row[3], + "antigen_value": row[4], + "evidence_id": row[5], + "observation_id": row[6], + "from_state": row[7], + "to_state": row[8], + "reason": row[9], + "matched_regex_hash": row[10], + "matched_regex": row[11], + "matched_value": row[12], + "scores": _BaseTCellSQLiteDB._loads(row[13], {}), + "created_at": row[14], + } + + def insert_transition(self, record: dict) -> int: + cursor = self.execute( + "INSERT INTO transitions (" + "cell_key, profile_ip, regex_type, antigen_value, evidence_id, " + "observation_id, from_state, to_state, reason, matched_regex_hash, " + "matched_regex, matched_value, scores_json, created_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + record["cell_key"], + record["profile_ip"], + record["regex_type"], + record["antigen_value"], + record["evidence_id"], + record.get("observation_id"), + record.get("from_state"), + record.get("to_state"), + record["reason"], + record.get("matched_regex_hash"), + record.get("matched_regex"), + record.get("matched_value"), + json.dumps(record.get("scores", {})), + record.get("created_at") or time(), + ), + ) + return cursor.lastrowid if cursor else 0 + + def get_transitions(self, cell_key: str | None = None) -> list[dict]: + condition = None + params = () + if cell_key: + condition = "cell_key = ?" + params = (cell_key,) + rows = self.select( + "transitions", + condition=condition, + params=params, + order_by="created_at ASC, id ASC", + ) + rows = rows or [] + return [self._row_to_transition(row) for row in rows] + + def has_recent_regex_activity( + self, + profile_ip: str, + regex_hash: str, + since_ts: float, + exclude_observation_id: int | None = None, + ) -> bool: + condition = ( + "profile_ip = ? AND matched_regex_hash = ? AND created_at >= ?" + ) + params = [profile_ip, regex_hash, since_ts] + if exclude_observation_id is not None: + condition += " AND (observation_id IS NULL OR observation_id != ?)" + params.append(exclude_observation_id) + row = self.select( + "transitions", + columns="id", + condition=condition, + params=tuple(params), + limit=1, + ) + return bool(row) + + @staticmethod + def _row_to_memory(row) -> dict: + return { + "cell_key": row[0], + "profile_ip": row[1], + "regex_type": row[2], + "antigen_value": row[3], + "regex_hash": row[4], + "regex": row[5], + "matched_value": row[6], + "context": _BaseTCellSQLiteDB._loads(row[7], {}), + "created_at": row[8], + "updated_at": row[9], + } + + def upsert_memory(self, record: dict): + self.execute( + "INSERT OR REPLACE INTO memories (" + "cell_key, profile_ip, regex_type, antigen_value, regex_hash, regex, " + "matched_value, context_json, created_at, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + record["cell_key"], + record["profile_ip"], + record["regex_type"], + record["antigen_value"], + record["regex_hash"], + record["regex"], + record["matched_value"], + json.dumps(record.get("context", {})), + record["created_at"], + record["updated_at"], + ), + ) + + def has_memory_for_regex(self, regex_hash: str) -> bool: + row = self.select( + "memories", + columns="cell_key", + condition="regex_hash = ?", + params=(regex_hash,), + limit=1, + ) + return bool(row) + + def get_memories(self) -> list[dict]: + rows = self.select("memories", order_by="updated_at DESC") or [] + return [self._row_to_memory(row) for row in rows] + + +class TCellStorage: + def __init__( + self, + logger: Output, + conf, + output_dir: str, + main_pid: int, + ): + self.logger = logger + self.conf = conf + self.output_dir = output_dir + self.main_pid = main_pid + self.store_dir = self._resolve_store_dir() + self.db = TCellSQLiteDB( + self.logger, + str(Path(self.store_dir) / "t_cell.sqlite"), + self.main_pid, + ) + + def _resolve_store_dir(self) -> str: + raw_store_dir = self._read_store_dir() + store_dir = self._normalize_store_dir(raw_store_dir) + store_dir.mkdir(parents=True, exist_ok=True) + return str(store_dir) + + def _normalize_store_dir(self, raw_store_dir: str) -> Path: + store_dir = Path(raw_store_dir).expanduser() + if store_dir.is_absolute(): + return store_dir + + relative_parts = list(store_dir.parts) + while relative_parts and relative_parts[0] == ".": + relative_parts = relative_parts[1:] + if relative_parts and relative_parts[0] == "output": + relative_parts = relative_parts[1:] + if not relative_parts: + relative_parts = ["t_cell"] + return Path(self.output_dir).expanduser().joinpath(*relative_parts) + + def _read_store_dir(self) -> str: + persistent_value = self._read_string_config( + "t_cell_persistent_store_dir" + ) + if persistent_value: + return persistent_value + + value = self._read_string_config("t_cell_store_dir") + if value: + return value + + parser = ConfigParser() + persistent_getter = getattr(parser, "t_cell_persistent_store_dir", None) + if callable(persistent_getter): + try: + persistent_value = persistent_getter() + except TypeError: + persistent_value = None + if isinstance(persistent_value, str) and persistent_value.strip(): + return persistent_value.strip() + + parser_getter = getattr(parser, "t_cell_store_dir", None) + if callable(parser_getter): + try: + value = parser_getter() + except TypeError: + value = None + if isinstance(value, str) and value.strip(): + return value.strip() + return DEFAULT_T_CELL_STORE_DIR + + def _read_string_config(self, method_name: str) -> str | None: + getter = getattr(self.conf, method_name, None) + if not callable(getter): + return None + try: + value = getter() + except TypeError: + return None + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + def insert_observation(self, record: dict) -> int: + return self.db.insert_observation(record) + + def get_observation(self, observation_id: int) -> dict | None: + return self.db.get_observation(observation_id) + + def update_observation_matches( + self, observation_id: int, matched_regexes: list[dict] + ): + self.db.update_observation_matches(observation_id, matched_regexes) + + def get_recent_observations( + self, + profile_ip: str, + since_ts: float, + until_ts: float | None = None, + evidence_signal: str | None = None, + ) -> list[dict]: + return self.db.get_recent_observations( + profile_ip, + since_ts, + until_ts=until_ts, + evidence_signal=evidence_signal, + ) + + def prune_observations(self, created_before: float): + self.db.prune_observations(created_before) + + def get_cell(self, cell_key: str) -> dict | None: + return self.db.get_cell(cell_key) + + def get_all_cells(self) -> list[dict]: + return self.db.get_all_cells() + + def upsert_cell(self, record: dict): + self.db.upsert_cell(record) + + def insert_transition(self, record: dict) -> int: + return self.db.insert_transition(record) + + def get_transitions(self, cell_key: str | None = None) -> list[dict]: + return self.db.get_transitions(cell_key) + + def has_recent_regex_activity( + self, + profile_ip: str, + regex_hash: str, + since_ts: float, + exclude_observation_id: int | None = None, + ) -> bool: + return self.db.has_recent_regex_activity( + profile_ip, + regex_hash, + since_ts, + exclude_observation_id=exclude_observation_id, + ) + + def upsert_memory(self, record: dict): + self.db.upsert_memory(record) + + def has_memory_for_regex(self, regex_hash: str) -> bool: + return self.db.has_memory_for_regex(regex_hash) + + def get_memories(self) -> list[dict]: + return self.db.get_memories() + + def close(self): + self.db.close() From 77997fd25df8e74b4444b971cd2c5e32cf8288a3 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:30:54 +0000 Subject: [PATCH 0112/1100] feat: add TCell object creation method to ModuleFactory for enhanced T Cell management in tests --- tests/module_factory.py | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/module_factory.py b/tests/module_factory.py index 09c25dc2e5..1ce22faa50 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -86,6 +86,10 @@ def create_db_manager_obj( return_value=10000 ) conf.regex_generator_seed_benign_samples = Mock(return_value=True) + conf.t_cell_store_dir = Mock( + return_value=os.path.join(output_dir, "t_cell") + ) + conf.t_cell_persistent_store_dir = Mock(return_value="") conf.tranco_top_benign_limit = Mock(return_value=1000) with ( @@ -242,6 +246,62 @@ def create_regex_generator_obj(self, mock_db, store_dir="dummy_output_dir/regex_ regex_generator.print = Mock() return regex_generator + @patch(MODULE_DB_MANAGER, name="mock_db") + def create_t_cell_obj(self, mock_db): + from modules.t_cell.t_cell import TCell + + conf = Mock() + conf.t_cell_enabled = Mock(return_value=True) + conf.t_cell_create_log_file = Mock(return_value=True) + conf.t_cell_log_colors = Mock(return_value=True) + conf.t_cell_store_dir = Mock(return_value="dummy_output_dir/t_cell") + conf.t_cell_persistent_store_dir = Mock(return_value="") + conf.t_cell_observation_retention_seconds = Mock(return_value=604800) + conf.t_cell_anergy_ttl_seconds = Mock(return_value=21600) + conf.t_cell_related_lookback_seconds = Mock(return_value=3600) + conf.t_cell_related_pamps_saturation = Mock(return_value=5.0) + conf.t_cell_danger_saturation = Mock(return_value=2.5) + conf.t_cell_co_stimulation_threshold = Mock(return_value=0.65) + conf.t_cell_co_stimulation_weights = Mock( + return_value={ + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, + } + ) + conf.t_cell_novelty_window_seconds = Mock(return_value=86400) + conf.t_cell_context_recent_window_seconds = Mock(return_value=1800) + conf.t_cell_effector_threshold = Mock(return_value=0.70) + conf.t_cell_effector_min_related_count = Mock(return_value=4) + conf.t_cell_effector_cooldown_seconds = Mock(return_value=1800) + conf.t_cell_memory_threshold = Mock(return_value=0.60) + conf.t_cell_memory_trend_ratio_max = Mock(return_value=0.60) + conf.t_cell_memory_min_related_count = Mock(return_value=3) + conf.t_cell_simulate_effector_without_blocking = Mock( + return_value=True + ) + + args = Mock() + args.interface = None + args.access_point = False + + t_cell = TCell( + logger=self.logger, + output_dir="dummy_output_dir", + redis_port=6379, + termination_event=Mock(), + slips_args=args, + conf=conf, + ppid=12345, + bloom_filters_manager=Mock(), + ) + t_cell.db.get_generated_regexes.return_value = [] + t_cell.db.get_altflow_from_uid.return_value = {} + t_cell.db.get_pid_of.return_value = None + t_cell.db.publish = Mock() + t_cell.print = Mock() + return t_cell + @patch(MODULE_DB_MANAGER, name="mock_db") def create_fides_module_obj(self, mock_db): from modules.fidesModule.fidesModule import FidesModule From 8934a2599ab40a777b92779793f3d6afe3a5c9f5 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:31:02 +0000 Subject: [PATCH 0113/1100] feat: add unit tests for TCellStorage CRUD operations and persistence handling --- tests/unit/modules/t_cell/test_t_cell_db.py | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/unit/modules/t_cell/test_t_cell_db.py diff --git a/tests/unit/modules/t_cell/test_t_cell_db.py b/tests/unit/modules/t_cell/test_t_cell_db.py new file mode 100644 index 0000000000..6704e6ec0b --- /dev/null +++ b/tests/unit/modules/t_cell/test_t_cell_db.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +from unittest.mock import Mock + +from slips_files.core.database.sqlite_db.t_cell_db import TCellStorage + + +def _build_storage(tmp_path, persistent_store_dir: str = ""): + conf = Mock() + conf.t_cell_store_dir = Mock(return_value="output/t_cell") + conf.t_cell_persistent_store_dir = Mock( + return_value=persistent_store_dir + ) + return TCellStorage(Mock(), conf, str(tmp_path), 12345) + + +def test_t_cell_storage_uses_persistent_store_dir_when_configured(tmp_path): + persistent_dir = tmp_path / "persistent-store" + storage = _build_storage(tmp_path, persistent_store_dir=str(persistent_dir)) + + assert storage.store_dir == str(persistent_dir) + assert storage.db.db_path == str(persistent_dir / "t_cell.sqlite") + + +def test_t_cell_storage_crud_and_pruning(tmp_path): + storage = _build_storage(tmp_path) + observation_id = storage.insert_observation( + { + "evidence_id": "obs-1", + "evidence_type": "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "evidence_signal": "PAMP", + "profile_ip": "10.0.0.50", + "timewindow_number": 1, + "timestamp": "2023/11/14 22:13:20.000000+0000", + "observed_at": 100.0, + "confidence": 0.9, + "threat_level": "high", + "threat_level_value": 0.8, + "interface": "default", + "uids": ["uid-1"], + "antigen_count": 1, + "antigens": [{"regex_type": "dns_domain", "value": "bad.example.com"}], + "matched_regexes": [], + "raw_evidence": {"id": "obs-1"}, + } + ) + + storage.update_observation_matches( + observation_id, + [ + { + "regex_type": "dns_domain", + "value": "bad.example.com", + "regex_hash": "hash-1", + "regex": r"^bad\.example\.com$", + } + ], + ) + storage.upsert_cell( + { + "cell_key": "10.0.0.50|dns_domain|bad.example.com", + "profile_ip": "10.0.0.50", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "state": 4, + "state_name": "4 - effector", + "matched_regex_hash": "hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "anergic_until": None, + "effector_cooldown_until": 500.0, + "last_observation_id": observation_id, + "last_evidence_id": "obs-1", + "last_transition_at": 100.0, + "last_co_stimulation": 0.9, + "last_effector_score": 0.95, + "last_memory_score": 0.1, + "context": {"state": "effector"}, + "created_at": 100.0, + "updated_at": 100.0, + } + ) + storage.insert_transition( + { + "cell_key": "10.0.0.50|dns_domain|bad.example.com", + "profile_ip": "10.0.0.50", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "obs-1", + "observation_id": observation_id, + "from_state": 3, + "to_state": 4, + "reason": "context_effector", + "matched_regex_hash": "hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"effector_score": 0.95}, + "created_at": 100.0, + } + ) + storage.upsert_memory( + { + "cell_key": "10.0.0.50|dns_domain|bad.example.com", + "profile_ip": "10.0.0.50", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "regex_hash": "hash-1", + "regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "context": {"memory_score": 0.7}, + "created_at": 100.0, + "updated_at": 100.0, + } + ) + + observation = storage.get_observation(observation_id) + cells = storage.get_all_cells() + transitions = storage.get_transitions() + memories = storage.get_memories() + + assert observation["matched_regexes"][0]["regex_hash"] == "hash-1" + assert cells[0]["state"] == 4 + assert transitions[0]["reason"] == "context_effector" + assert memories[0]["regex_hash"] == "hash-1" + assert storage.has_recent_regex_activity( + "10.0.0.50", "hash-1", since_ts=50.0 + ) + assert storage.has_memory_for_regex("hash-1") is True + + storage.prune_observations(101.0) + assert storage.get_recent_observations("10.0.0.50", 0) == [] From 1337045a790baa69a3619dcbf8b43bb9bd32e513 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:31:09 +0000 Subject: [PATCH 0114/1100] feat: add comprehensive unit tests for TCell functionality and evidence processing --- tests/unit/modules/t_cell/test_t_cell.py | 466 +++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 tests/unit/modules/t_cell/test_t_cell.py diff --git a/tests/unit/modules/t_cell/test_t_cell.py b/tests/unit/modules/t_cell/test_t_cell.py new file mode 100644 index 0000000000..8e0f4ebfa8 --- /dev/null +++ b/tests/unit/modules/t_cell/test_t_cell.py @@ -0,0 +1,466 @@ +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +from unittest.mock import Mock, patch + +from modules.t_cell.t_cell import ( + STATE_ANERGIC, + STATE_ANTIGEN_RECOGNIZED, + STATE_EFFECTOR, + STATE_MEMORY, + AntigenCandidate, + RegexMatch, +) +from slips_files.common.slips_utils import utils +from slips_files.core.database.sqlite_db.t_cell_db import TCellStorage +from slips_files.core.structures.evidence import ( + Attacker, + Direction, + Evidence, + EvidenceSignal, + EvidenceType, + IoCType, + Method, + ProfileID, + Proto, + ThreatLevel, + TimeWindow, + Victim, +) +from tests.module_factory import ModuleFactory + +TEST_TS = utils.convert_ts_format(1700000000, utils.alerts_format) + + +def _build_storage(tmp_path): + conf = Mock() + conf.t_cell_store_dir = Mock(return_value="output/t_cell") + conf.t_cell_persistent_store_dir = Mock(return_value="") + return TCellStorage(Mock(), conf, str(tmp_path), 12345) + + +def _prepare_t_cell(tmp_path): + t_cell = ModuleFactory().create_t_cell_obj() + t_cell.output_dir = str(tmp_path) + t_cell.log_file_path = str(tmp_path / "t_cell.log") + storage = _build_storage(tmp_path) + t_cell.db.get_t_cell_storage.return_value = storage + with patch("modules.t_cell.t_cell.utils.drop_root_privs_permanently"): + assert t_cell.pre_main() is False + return t_cell, storage + + +def _build_evidence( + evidence_id: str, + signal: EvidenceSignal = EvidenceSignal.PAMP, + attacker=None, + victim=None, + uids=None, + profile_ip: str = "10.0.0.50", + threat_level: ThreatLevel = ThreatLevel.HIGH, + confidence: float = 1.0, +): + attacker = attacker or Attacker( + direction=Direction.SRC, + ioc_type=IoCType.IP, + value=profile_ip, + ) + evidence = Evidence( + evidence_type=EvidenceType.THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN, + description="test evidence", + attacker=attacker, + victim=victim, + threat_level=threat_level, + profile=ProfileID(ip=profile_ip), + timewindow=TimeWindow(number=1), + uid=uids or ["uid-1"], + timestamp=TEST_TS, + proto=Proto.TCP, + dst_port=443, + method=Method.HEURISTIC, + id=evidence_id, + confidence=confidence, + ) + evidence.evidence_signal = signal + return evidence + + +def _message_for(evidence: Evidence) -> dict: + return {"data": json.dumps(utils.to_dict(evidence))} + + +def _insert_observation( + storage, + evidence_id: str, + profile_ip: str, + antigens: list[dict], + observed_at: float, + confidence: float, + threat_level_value: float, + threat_level: str = "high", + matched_regexes: list[dict] | None = None, +): + return storage.insert_observation( + { + "evidence_id": evidence_id, + "evidence_type": "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "evidence_signal": "PAMP", + "profile_ip": profile_ip, + "timewindow_number": 1, + "timestamp": TEST_TS, + "observed_at": observed_at, + "confidence": confidence, + "threat_level": threat_level, + "threat_level_value": threat_level_value, + "interface": "default", + "uids": [f"{evidence_id}-uid"], + "antigen_count": len(antigens), + "antigens": antigens, + "matched_regexes": matched_regexes or [], + "raw_evidence": {}, + } + ) + + +def _seed_recent_related_observations( + storage, + profile_ip: str, + antigen: AntigenCandidate, + fixed_now: float, + count: int, + confidence: float = 1.0, + threat_level_value: float = 0.8, + age_seconds: int = 300, +): + for index in range(count): + _insert_observation( + storage=storage, + evidence_id=f"hist-recent-{index}", + profile_ip=profile_ip, + antigens=[antigen.as_dict()], + observed_at=fixed_now - age_seconds - index, + confidence=confidence, + threat_level_value=threat_level_value, + ) + + +def _accepted_domain_regex(regex_hash: str = "regex-hash") -> list[dict]: + return [ + { + "regex_type": "dns_domain", + "regex": r"^bad\.example\.com$", + "regex_hash": regex_hash, + "created_at": 10, + } + ] + + +def test_extract_antigen_candidates_from_entities_and_altflows(tmp_path): + t_cell, _ = _prepare_t_cell(tmp_path) + attacker = Attacker( + direction=Direction.SRC, + ioc_type=IoCType.URL, + value="https://download.bad.example.com/payload/run.exe?stage=2", + ) + victim = Victim( + direction=Direction.DST, + ioc_type=IoCType.DOMAIN, + value="victim.bad.example.com", + SNI="sni.bad.example.com", + ) + evidence = _build_evidence( + "extract-1", + attacker=attacker, + victim=victim, + uids=["dns-1", "http-1", "ssl-1"], + ) + t_cell.db.get_altflow_from_uid.side_effect = lambda uid: { + "dns-1": {"type_": "dns", "query": "dns.bad.example.com"}, + "http-1": { + "type_": "http", + "host": "http.bad.example.com", + "uri": "/dropper/setup.exe", + }, + "ssl-1": { + "type_": "ssl", + "server_name": "tls.bad.example.com", + "subject": "C=US,O=Test,CN=cn.bad.example.com", + }, + }[uid] + + extracted = { + (item.regex_type, item.value) + for item in t_cell._extract_antigen_candidates(evidence) + } + + assert ("dns_domain", "download.bad.example.com") in extracted + assert ("dns_domain", "victim.bad.example.com") in extracted + assert ("dns_domain", "dns.bad.example.com") in extracted + assert ("dns_domain", "http.bad.example.com") in extracted + assert ("uri", "/payload/run.exe?stage=2") in extracted + assert ("uri", "/dropper/setup.exe") in extracted + assert ("filename", "run.exe") in extracted + assert ("filename", "setup.exe") in extracted + assert ("tls_sni", "sni.bad.example.com") in extracted + assert ("tls_sni", "tls.bad.example.com") in extracted + assert ("certificate_cn", "cn.bad.example.com") in extracted + + +def test_t_cell_ignores_damp_evidence(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path) + evidence = _build_evidence("damp-1", signal=EvidenceSignal.DAMP) + + with patch("modules.t_cell.t_cell.time.time", return_value=2000.0): + t_cell._process_evidence_message(_message_for(evidence)) + + observations = storage.get_recent_observations(evidence.profile.ip, 0) + assert len(observations) == 1 + assert observations[0]["evidence_signal"] == "DAMP" + assert storage.get_all_cells() == [] + t_cell.db.publish.assert_not_called() + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + assert "ignored_non_pamp" in log_file.read() + + +def test_t_cell_skips_pamp_without_antigens(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path) + evidence = _build_evidence("no-antigen-1") + + with patch("modules.t_cell.t_cell.time.time", return_value=3000.0): + t_cell._process_evidence_message(_message_for(evidence)) + + assert storage.get_all_cells() == [] + assert t_cell.db.publish.call_count == 0 + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + assert "no_antigen_extracted" in log_file.read() + + +def test_t_cell_no_match_becomes_anergic_and_expires(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path) + evidence = _build_evidence("anergy-1", uids=["http-1"]) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "http", + "host": "bad.example.com", + "uri": "/setup.exe", + } + t_cell.db.get_generated_regexes.return_value = [] + + with patch("modules.t_cell.t_cell.time.time", return_value=4000.0): + t_cell._process_evidence_message(_message_for(evidence)) + + cell = storage.get_all_cells()[0] + assert cell["state"] == STATE_ANERGIC + assert cell["anergic_until"] == 4000.0 + t_cell.anergy_ttl_seconds + + evidence2 = _build_evidence("anergy-2", uids=["http-1"]) + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex() + with patch( + "modules.t_cell.t_cell.time.time", + return_value=4000.0 + t_cell.anergy_ttl_seconds + 1, + ): + t_cell._process_evidence_message(_message_for(evidence2)) + + cell = storage.get_all_cells()[0] + transitions = [ + transition["reason"] + for transition in storage.get_transitions(cell["cell_key"]) + ] + assert "anergy_expired" in transitions + assert cell["state"] == STATE_ANTIGEN_RECOGNIZED + + +def test_find_best_regex_match_prefers_specificity_and_newest(tmp_path): + t_cell, _ = _prepare_t_cell(tmp_path) + t_cell.db.get_generated_regexes.return_value = [ + { + "regex_type": "dns_domain", + "regex": r"example\.com$", + "regex_hash": "broad", + "created_at": 1, + }, + { + "regex_type": "dns_domain", + "regex": r"^bad\.example\.com$", + "regex_hash": "specific-old", + "created_at": 2, + }, + { + "regex_type": "dns_domain", + "regex": r"^bad\.example\.com$", + "regex_hash": "specific-new", + "created_at": 3, + }, + ] + + match = t_cell._find_best_regex_match( + AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + ) + + assert match.regex_hash == "specific-new" + assert match.regex == r"^bad\.example\.com$" + + +def test_t_cell_effector_publishes_blocking_and_respects_cooldown(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path) + fixed_now = 10_000.0 + profile_ip = "10.0.0.60" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence = _build_evidence("effector-1", profile_ip=profile_ip, uids=["dns-1"]) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "live-effector" + ) + t_cell.db.get_pid_of.side_effect = lambda name: 123 if name == "Blocking" else None + _seed_recent_related_observations( + storage, profile_ip, antigen, fixed_now, count=4 + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence)) + + assert t_cell.db.publish.call_count == 1 + channel, payload = t_cell.db.publish.call_args.args + assert channel == "new_blocking" + assert json.loads(payload) == { + "ip": profile_ip, + "block": True, + "tw": 1, + "interface": None, + } + + cell = storage.get_all_cells()[0] + assert cell["state"] == STATE_EFFECTOR + match = RegexMatch( + regex_type="dns_domain", + value="bad.example.com", + regex_hash="live-effector", + regex=r"^bad\.example\.com$", + created_at=10, + specificity=10.0, + ) + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 1): + t_cell._apply_effector( + cell, + evidence, + match, + {"effector_score": 0.95}, + fixed_now + 1, + ) + + assert t_cell.db.publish.call_count == 1 + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + assert "effector_cooldown" in log_file.read() + + +def test_t_cell_simulates_effector_without_blocking_modules(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path) + fixed_now = 11_000.0 + profile_ip = "10.0.0.61" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence = _build_evidence("simulate-1", profile_ip=profile_ip, uids=["dns-1"]) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "sim-effector" + ) + t_cell.db.get_pid_of.return_value = None + _seed_recent_related_observations( + storage, profile_ip, antigen, fixed_now, count=4 + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence)) + + assert t_cell.db.publish.call_count == 0 + assert storage.get_all_cells()[0]["state"] == STATE_EFFECTOR + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + assert "effector_simulated" in log_file.read() + + +def test_t_cell_moves_to_memory_and_stores_context(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path) + fixed_now = 12_000.0 + profile_ip = "10.0.0.62" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence = _build_evidence( + "memory-1", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.MEDIUM, + confidence=0.5, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "memory-regex" + ) + t_cell.db.get_pid_of.return_value = None + + for index in range(5): + _insert_observation( + storage=storage, + evidence_id=f"hist-old-{index}", + profile_ip=profile_ip, + antigens=[antigen.as_dict()], + observed_at=fixed_now - 2400 - index, + confidence=1.0, + threat_level_value=0.8, + ) + for index in range(3): + _insert_observation( + storage=storage, + evidence_id=f"hist-new-{index}", + profile_ip=profile_ip, + antigens=[antigen.as_dict()], + observed_at=fixed_now - 300 - index, + confidence=0.5, + threat_level_value=0.5, + threat_level="medium", + ) + storage.upsert_memory( + { + "cell_key": "old-memory-cell", + "profile_ip": "10.0.0.1", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "regex_hash": "memory-regex", + "regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "context": {"seeded": True}, + "created_at": fixed_now - 100, + "updated_at": fixed_now - 100, + } + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence)) + + cell = storage.get_all_cells()[0] + memories = storage.get_memories() + assert cell["state"] == STATE_MEMORY + assert any(memory["cell_key"] == cell["cell_key"] for memory in memories) + + +def test_t_cell_log_file_contains_color_codes(tmp_path): + t_cell, _ = _prepare_t_cell(tmp_path) + evidence = _build_evidence("log-1") + + t_cell._log_event( + action="test_log", + state=STATE_EFFECTOR, + evidence=evidence, + metrics={"score": 0.95}, + ) + + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + log_contents = log_file.read() + + assert "\033[" in log_contents + assert "4 - effector" in log_contents From 602be1eee6628351431ba65181cc383ffcf5c6b4 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 13:31:15 +0000 Subject: [PATCH 0115/1100] feat: enhance TCell configuration tests with defaults and sanitization checks --- .../slips_files/common/test_config_parser.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/unit/slips_files/common/test_config_parser.py b/tests/unit/slips_files/common/test_config_parser.py index 5ca202ced4..ea8ba83b64 100644 --- a/tests/unit/slips_files/common/test_config_parser.py +++ b/tests/unit/slips_files/common/test_config_parser.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2021 Sebastian Garcia # SPDX-License-Identifier: GPL-2.0-only from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.common.input_type import InputType def test_evidence_signal_default_falls_back_to_pamp(): @@ -27,3 +28,110 @@ def test_evidence_signal_overrides_sanitizes_values(): "MALICIOUS_FLOW": "DAMP", "SSH_SUCCESSFUL": "PAMP", } + + +def test_t_cell_config_defaults(): + parser = ConfigParser.__new__(ConfigParser) + parser.config = {} + + assert parser.t_cell_enabled() is False + assert parser.t_cell_create_log_file() is True + assert parser.t_cell_log_colors() is True + assert parser.t_cell_store_dir() == "output/t_cell" + assert parser.t_cell_persistent_store_dir() == "" + assert parser.t_cell_observation_retention_seconds() == 604800 + assert parser.t_cell_anergy_ttl_seconds() == 21600 + assert parser.t_cell_related_lookback_seconds() == 3600 + assert parser.t_cell_related_pamps_saturation() == 5 + assert parser.t_cell_danger_saturation() == 2.5 + assert parser.t_cell_co_stimulation_threshold() == 0.65 + assert parser.t_cell_co_stimulation_weights() == { + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, + } + assert parser.t_cell_novelty_window_seconds() == 86400 + assert parser.t_cell_context_recent_window_seconds() == 1800 + assert parser.t_cell_effector_threshold() == 0.70 + assert parser.t_cell_effector_min_related_count() == 4 + assert parser.t_cell_effector_cooldown_seconds() == 1800 + assert parser.t_cell_memory_threshold() == 0.60 + assert parser.t_cell_memory_trend_ratio_max() == 0.60 + assert parser.t_cell_memory_min_related_count() == 3 + assert parser.t_cell_simulate_effector_without_blocking() is True + + +def test_t_cell_config_sanitization(): + parser = ConfigParser.__new__(ConfigParser) + parser.config = { + "t_cell": { + "enabled": "true", + "create_log_file": "false", + "log_colors": "false", + "store_dir": "", + "persistent_store_dir": " /tmp/tcell ", + "observation_retention_seconds": "bad", + "anergy_ttl_seconds": -2, + "related_lookback_seconds": "bad", + "related_pamps_saturation": "bad", + "danger_saturation": 0, + "co_stimulation_threshold": "bad", + "co_stimulation_weights": { + "confidence": 0, + "related_pamps": 0, + "danger": 0, + }, + "novelty_window_seconds": "bad", + "context_recent_window_seconds": 0, + "effector_threshold": 2, + "effector_min_related_count": "bad", + "effector_cooldown_seconds": "bad", + "memory_threshold": "bad", + "memory_trend_ratio_max": "bad", + "memory_min_related_count": "bad", + "simulate_effector_without_blocking": "false", + } + } + + assert parser.t_cell_enabled() is True + assert parser.t_cell_create_log_file() is False + assert parser.t_cell_log_colors() is False + assert parser.t_cell_store_dir() == "output/t_cell" + assert parser.t_cell_persistent_store_dir() == "/tmp/tcell" + assert parser.t_cell_observation_retention_seconds() == 604800 + assert parser.t_cell_anergy_ttl_seconds() == 0 + assert parser.t_cell_related_lookback_seconds() == 3600 + assert parser.t_cell_related_pamps_saturation() == 5 + assert parser.t_cell_danger_saturation() == 0.01 + assert parser.t_cell_co_stimulation_threshold() == 0.65 + assert parser.t_cell_co_stimulation_weights() == { + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, + } + assert parser.t_cell_novelty_window_seconds() == 86400 + assert parser.t_cell_context_recent_window_seconds() == 1 + assert parser.t_cell_effector_threshold() == 1.0 + assert parser.t_cell_effector_min_related_count() == 4 + assert parser.t_cell_effector_cooldown_seconds() == 1800 + assert parser.t_cell_memory_threshold() == 0.60 + assert parser.t_cell_memory_trend_ratio_max() == 0.60 + assert parser.t_cell_memory_min_related_count() == 3 + assert parser.t_cell_simulate_effector_without_blocking() is False + + +def test_get_disabled_modules_tracks_t_cell_enablement(): + parser = ConfigParser.__new__(ConfigParser) + parser.config = { + "modules": {"disable": ["template"]}, + "llm": {"enabled": True}, + "regex_generator": {"enabled": True}, + "t_cell": {"enabled": False}, + } + + disabled = parser.get_disabled_modules(InputType.PCAP) + assert "t_cell" in disabled + + parser.config["t_cell"]["enabled"] = True + disabled = parser.get_disabled_modules(InputType.PCAP) + assert "t_cell" not in disabled From 7f6658a0f90e20f574ef4909639a1e4d109d6b47 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:16:23 +0000 Subject: [PATCH 0116/1100] feat: enable T Cell module and enhance logging and danger observation parameters --- config/slips.yaml | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/config/slips.yaml b/config/slips.yaml index 268a8121e9..524ea0ca61 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -333,7 +333,7 @@ regex_generator: ############################# t_cell: # Enable the immune-inspired T Cell responder module. - enabled: false + enabled: true # Create output/t_cell.log with human-readable transition logs. create_log_file: true @@ -341,6 +341,12 @@ t_cell: # Keep ANSI colors in the T Cell log file for quick scanning. log_colors: true + # File log verbosity: + # 1 = transitions and terminal actions only + # 2 = add decision summaries such as waiting for co-stimulation/context + # 3 = add per-evidence debug details such as extracted antigens + log_verbosity: 1 + # Directory that stores the isolated T Cell SQLite database. # Absolute paths are used as-is. Relative paths are resolved inside the # output directory of the current Slips run. @@ -367,6 +373,12 @@ t_cell: # sum(threat_level_value * confidence) / danger_saturation danger_saturation: 2.5 + # DAMP observations do not create or match T Cells, but they do increase + # the danger pressure used by co-stimulation and context for the same + # profile IP: + # combined_raw_danger = pamp_raw_danger + damp_danger_weight * damp_raw_danger + damp_danger_weight: 1.5 + # Activation threshold for co-stimulation. co_stimulation_threshold: 0.65 @@ -737,6 +749,28 @@ EvidenceSignals: # override it here. overrides: MALICIOUS_FLOW: DAMP + ARP_SCAN: DAMP + UNSOLICITED_ARP: DAMP + CONNECTION_TO_MULTIPLE_PORTS: DAMP + CONNECTION_TO_PRIVATE_IP: DAMP + CONNECTION_WITHOUT_DNS: DAMP + DNS_ARPA_SCAN: DAMP + DNS_WITHOUT_CONNECTION: DAMP + HIGH_ENTROPY_DNS_ANSWER: DAMP + LONG_CONNECTION: DAMP + MULTIPLE_RECONNECTION_ATTEMPTS: DAMP + MULTIPLE_SSH_VERSIONS: DAMP + NON_SSL_PORT_443_CONNECTION: DAMP + UNKNOWN_PORT: DAMP + YOUNG_DOMAIN: DAMP + HTTP_TRAFFIC: DAMP + MULTIPLE_USER_AGENT: DAMP + NON_HTTP_PORT_80_CONNECTION: DAMP + MALICIOUS_IP_FROM_P2P_NETWORK: DAMP + + + + ############################# Docker: From 73b9688a0f17bc5816910b552f824d70bca636f7 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:26:00 +0000 Subject: [PATCH 0117/1100] feat: update DisabledAlerts and EvidenceSignals for improved anomaly detection handling --- config/slips.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/config/slips.yaml b/config/slips.yaml index 524ea0ca61..9cdf70dc9b 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -718,7 +718,7 @@ DisabledAlerts: # CONNECTION_TO_MULTIPLE_PORTS, HIGH_ENTROPY_DNS_ANSWER, # INVALID_DNS_RESOLUTION, PORT_0_CONNECTION, MALICIOUS_JA3, MALICIOUS_JA3S, # DATA_UPLOAD, BAD_SMTP_LOGIN, SMTP_LOGIN_BRUTEFORCE, MALICIOUS_SSL_CERT, - # MALICIOUS_FLOW, SUSPICIOUS_USER_AGENT, EMPTY_CONNECTIONS, + # ANOMALOUS_FLOW, MALICIOUS_FLOW, SUSPICIOUS_USER_AGENT, EMPTY_CONNECTIONS, # INCOMPATIBLE_USER_AGENT, EXECUTABLE_MIME_TYPE, MULTIPLE_USER_AGENT, # HTTP_TRAFFIC, MALICIOUS_JARM, NETWORK_GPS_LOCATION_LEAKED, # ICMP_TIMESTAMP_SCAN, ICMP_ADDRESS_SCAN, ICMP_ADDRESS_MASK_SCAN, @@ -744,10 +744,11 @@ EvidenceSignals: default_signal: PAMP # Override the evidence signal per evidence type. - # By default MALICIOUS_FLOW is marked as DAMP because it is emitted by the - # anomaly detection modules. Everything else defaults to PAMP unless you - # override it here. + # By default ANOMALOUS_FLOW and MALICIOUS_FLOW are marked as DAMP because + # they are emitted by anomaly-detection modules. You can also override + # additional evidence types here. overrides: + ANOMALOUS_FLOW: DAMP MALICIOUS_FLOW: DAMP ARP_SCAN: DAMP UNSOLICITED_ARP: DAMP From 7c86cd2a053329583dc81e89bba1044db9749200 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:26:07 +0000 Subject: [PATCH 0118/1100] feat: enhance T Cell module description with PAMP and DAMP context --- docs/detection_modules.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/detection_modules.md b/docs/detection_modules.md index 18dd3ee497..0242762f34 100644 --- a/docs/detection_modules.md +++ b/docs/detection_modules.md @@ -171,7 +171,8 @@ extracts structured antigens from evidence and linked altflows, and checks those values against accepted regexes already stored by `RegexGenerator`. Depending on co-stimulation and context signals, it becomes tolerant, activates, requests containment over `new_blocking`, or stores memory in its -own SQLite DB. +own SQLite DB. `PAMP`s drive antigen recognition, while stored `DAMP` +observations raise the danger pressure used by co-stimulation and context. For the full state machine, formulas, DB schema, and configuration, see [T Cell Module](t_cell_module.md). From 1ac6a0e721449ba5359460e51d7d014d999112ea Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:26:17 +0000 Subject: [PATCH 0119/1100] feat: update evidence signal configuration to include ANOMALOUS_FLOW as DAMP --- docs/evidence_signals.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md index b696e3662d..6123f48f8c 100644 --- a/docs/evidence_signals.md +++ b/docs/evidence_signals.md @@ -2,9 +2,11 @@ Slips now adds an `evidence_signal` field to every evidence when the evidence reaches the shared evidence pipeline. Detection modules do not need to set this field themselves. -The `T Cell` module consumes this same central field and, in v1, only activates -its state machine for `PAMP` evidence. `DAMP` evidence is still stored by the -module as an observation but is ignored for activation. See +The `T Cell` module consumes this same central field and only activates its +state machine for antigen recognition from `PAMP` evidence. `DAMP` evidence is +still stored by the module as an observation and contributes to the danger +pressure used in T-cell co-stimulation and context calculations for the same +profile IP, but it does not create cells or perform regex matching. See [T Cell Module](t_cell_module.md) for the responder details. The supported values are: @@ -22,6 +24,7 @@ Configure the default signal and per-evidence overrides in `config/slips.yaml`: EvidenceSignals: default_signal: PAMP overrides: + ANOMALOUS_FLOW: DAMP MALICIOUS_FLOW: DAMP ``` @@ -30,7 +33,7 @@ Rules: - `default_signal` is applied to every evidence type that is not listed in `overrides`. - `overrides` keys are evidence type names from `EvidenceType`. - Invalid values fall back to `PAMP`. -- The default shipped mapping marks `MALICIOUS_FLOW` as `DAMP`. +- The default shipped mapping marks `ANOMALOUS_FLOW` and `MALICIOUS_FLOW` as `DAMP`. ## Propagation @@ -47,7 +50,7 @@ The table below lists the evidence types currently emitted by Slips modules and | Module | Evidence type | Default signal | | --- | --- | --- | -| `anomaly_detection_https` | `MALICIOUS_FLOW` | `DAMP` | +| `anomaly_detection_https` | `ANOMALOUS_FLOW` | `DAMP` | | `arp` | `ARP_SCAN` | `PAMP` | | `arp` | `ARP_OUTSIDE_LOCALNET` | `PAMP` | | `arp` | `UNSOLICITED_ARP` | `PAMP` | @@ -116,4 +119,6 @@ The table below lists the evidence types currently emitted by Slips modules and | `threat_intelligence.urlhaus` | `MALICIOUS_DOWNLOADED_FILE` | `PAMP` | | `threat_intelligence.urlhaus` | `THREAT_INTELLIGENCE_MALICIOUS_URL` | `PAMP` | -`MALICIOUS_FLOW` is listed under both `anomaly_detection_https` and `flowmldetection` because both modules emit that evidence type. Since signal assignment is centralized by evidence type, both inherit the same default mapping unless overridden in configuration. +`ANOMALOUS_FLOW` is emitted by `anomaly_detection_https`, while `MALICIOUS_FLOW` +is emitted by `flowmldetection`. Both are marked as `DAMP` by default in the +central signal configuration. From 01af207495e64df3edaaca9c05ddaa3f414f5a12 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:26:24 +0000 Subject: [PATCH 0120/1100] feat: enhance T Cell module documentation to clarify DAMP handling and state transitions --- docs/t_cell_module.md | 113 ++++++++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 26 deletions(-) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index e0b7c6d03f..8b6f1e080a 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -4,10 +4,12 @@ The `T Cell` module is an immune-inspired responder that consumes centrally classified Slips evidence, looks for `PAMP`-tagged antigens that match the accepted RegexGenerator regex corpus, and then escalates through a small state machine until it either becomes tolerant, publishes a containment request, or -stores a memory snapshot for later reuse. +stores a memory snapshot for later reuse. `DAMP` observations do not perform +antigen recognition, but they do raise the danger pressure used later in +co-stimulation and context decisions. -The module is started by the normal Slips module loader when -`t_cell.enabled: true`. +The module is started by the normal Slips module loader and is enabled by +default through `t_cell.enabled: true`. ## Goals @@ -15,12 +17,16 @@ The module adds a second-stage decision layer without changing detector modules: 1. It listens to the shared `evidence_added` channel. -2. It ignores `DAMP` evidence for activation in v1. +2. It only creates or advances cells from `0 - mature` by using `PAMP` + evidence with extractable antigens. 3. It extracts structured antigen values from evidence and linked altflows. 4. It matches those values against accepted regexes already stored by `RegexGenerator`. -5. It computes co-stimulation and context scores. -6. It either becomes tolerant, activates, requests blocking, or stores memory. +5. It stores `DAMP` observations as profile-level danger signals and folds + them into co-stimulation and context pressure for later `PAMP` + reevaluations. +6. It computes co-stimulation and context scores. +7. It either becomes tolerant, activates, requests blocking, or stores memory. The target of any effector response is always `evidence.profile.ip`, matching the existing Slips blocking path. @@ -47,26 +53,37 @@ The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. 2. The module stores one observation row in its own SQLite DB. 3. If the evidence signal is not `PAMP`, the module logs `ignored_non_pamp` - and stops for that evidence. -4. If no structured antigen can be extracted, the module logs + and stops for that evidence after storing the observation. +4. Stored `DAMP` observations do not create or match cells, but they are kept + as danger inputs and are included in the next co-stimulation or context + evaluation for the same `profile.ip`. +5. If no structured antigen can be extracted, the module logs `no_antigen_extracted` and stops for that evidence. -5. For each antigen candidate, the module loads or creates the cell in +6. For each antigen candidate, the module loads or creates the cell in `0 - mature`. -6. If the cell is still under `anergic_until`, the module logs suppression and +7. If the cell is still under `anergic_until`, the module logs suppression and does nothing else. -7. If the cell is `2 - anergic` and the TTL expired, it transitions back to +8. If the cell is `2 - anergic` and the TTL expired, it transitions back to `0 - mature`. -8. If no accepted regex matches the antigen, the cell goes `0 -> 2` and stores +9. If no accepted regex matches the antigen, the cell goes `0 -> 2` and stores a new `anergic_until`. -9. If a regex matches, the cell goes `0 -> 1` and stores the chosen regex +10. If a regex matches, the cell goes `0 -> 1` and stores the chosen regex metadata. -10. The module computes co-stimulation. -11. If co-stimulation crosses the configured threshold, the cell goes `1 -> 3`. -12. In state `3`, the module computes context signals. -13. If the situation is novel and intense enough, the cell goes to +11. The module computes co-stimulation from the current `PAMP`, related + `PAMP`s, and stored `DAMP` danger pressure for the same profile. +12. If co-stimulation crosses the configured threshold, the cell goes `1 -> 3`. +13. If co-stimulation stays below threshold, the cell can wait in + `1 - antigen-recognized` for at most one configured Slips time window. +14. If that one-time-window wait expires without enough co-stimulation, the + cell goes `1 -> 2 - anergic`. +15. In state `3`, the module computes context signals from the same mixed + pressure model: related `PAMP`s plus weighted `DAMP` danger. +16. If the situation is novel and intense enough, the cell goes to `4 - effector`. -14. If the situation is familiar and clearly cooling down, the cell goes to +17. If the situation is familiar and clearly cooling down, the cell goes to `5 - memory`. +18. If state `3` cannot decide effector or memory within one configured Slips + time window, the cell goes `3 -> 0 - mature`. State `4` publishes the existing `new_blocking` payload when blocking support is present. If blocking or ARP poisoning modules are not running, the module @@ -133,7 +150,12 @@ Where: - `confidence = current evidence.confidence` - `related_pamp_score = min(1, related_pamp_count / related_pamps_saturation)` -- `profile_danger_score = min(1, sum(threat_level_value * confidence) / danger_saturation)` +- `profile_danger_score = min(1, combined_danger_raw / danger_saturation)` +- `combined_danger_raw = pamp_danger_raw + damp_danger_weight * damp_danger_raw` +- `pamp_danger_raw = sum(threat_level_value * confidence)` over recent `PAMP` + observations for the same `profile.ip` +- `damp_danger_raw = sum(threat_level_value * confidence)` over recent `DAMP` + observations for the same `profile.ip` Related PAMPs are recent `PAMP` observations for the same `profile.ip` that share either: @@ -146,11 +168,25 @@ Default weights are normalized from configuration: - `confidence = 0.35` - `related_pamps = 0.25` - `danger = 0.40` +- `damp_danger_weight = 1.5` Default activation threshold: - `co_stimulation_threshold = 0.65` +Interpretation: + +- `PAMP`s still provide antigen identity and the related-antigen correlation. +- `DAMP`s do not match regexes and do not create cells. +- `DAMP`s increase the danger term, so the same recognized antigen is treated + as riskier when the profile is also showing damage or anomaly signals. + +Wait limit: + +- state `1 - antigen-recognized` can wait for co-stimulation for at most one + configured Slips time window (`parameters.time_window_width`) +- if that wait expires, the cell goes `1 -> 2 - anergic` + ## Context Signals Context signals decide how to respond once a cell is activated. @@ -159,10 +195,12 @@ Definitions: - `novelty_score = 1` when the matched regex has no stored memory row and no recent prior regex activity in `novelty_window_seconds`; otherwise `0` -- `recent_pressure` is the normalized danger score over +- `recent_pressure` is the normalized combined danger score over `context_recent_window_seconds` -- `previous_pressure` is the same normalized danger score over the previous +- `previous_pressure` is the same combined danger score over the previous adjacent context window +- each pressure window uses + `combined_danger_raw = pamp_danger_raw + damp_danger_weight * damp_danger_raw` - `trend_ratio = recent_pressure / max(previous_pressure, 0.01)` - `recent_related_score = min(1, recent_related_count / related_pamps_saturation)` - `decrease_score = clamp(1 - trend_ratio, 0, 1)` @@ -201,6 +239,13 @@ Default decisions: If both would pass, `effector` wins. +Wait limit: + +- state `3 - activated` can wait for context for at most one configured Slips + time window (`parameters.time_window_width`) +- if that wait expires without effector or memory, the cell goes + `3 -> 0 - mature` + ## Containment Behavior When the cell reaches `4 - effector`, the module publishes the same payload @@ -274,6 +319,14 @@ decision or transition, with: - matched regex hash and value when relevant - main scores +`log_verbosity` controls how much decision detail is written: + +- `1`: transitions and terminal actions only +- `2`: also log why a cell is waiting, for example + `waiting_for_co_stimulation` with the current score, threshold, elapsed + wait time, wait limit, and the split between `PAMP` and `DAMP` danger +- `3`: also log per-evidence debug details such as extracted antigens + Color mapping: - `0 - mature` -> cyan @@ -289,9 +342,10 @@ Example section from `config/slips.yaml`: ```yaml t_cell: - enabled: false + enabled: true create_log_file: true log_colors: true + log_verbosity: 1 store_dir: output/t_cell persistent_store_dir: "" observation_retention_seconds: 604800 @@ -299,6 +353,7 @@ t_cell: related_lookback_seconds: 3600 related_pamps_saturation: 5 danger_saturation: 2.5 + damp_danger_weight: 1.5 co_stimulation_threshold: 0.65 co_stimulation_weights: confidence: 0.35 @@ -320,13 +375,17 @@ Reference: - `enabled`: enable or disable the module - `create_log_file`: create `output/t_cell.log` - `log_colors`: keep ANSI colors in the module log +- `log_verbosity`: `1` logs transitions/actions only, `2` adds decision + summaries, `3` adds per-evidence debug details - `store_dir`: run-local directory for the SQLite DB - `persistent_store_dir`: optional stable absolute directory for the DB - `observation_retention_seconds`: retention for observation rows - `anergy_ttl_seconds`: how long a non-matching cell remains tolerant - `related_lookback_seconds`: lookback for co-stimulation correlation - `related_pamps_saturation`: saturation point for related PAMP score -- `danger_saturation`: saturation point for weighted profile danger +- `danger_saturation`: saturation point for weighted combined profile danger +- `damp_danger_weight`: multiplier applied to raw `DAMP` danger before it is + added to the `PAMP` danger term - `co_stimulation_threshold`: threshold for `1 -> 3` - `co_stimulation_weights`: normalized internally - `novelty_window_seconds`: window for novelty suppression @@ -351,6 +410,8 @@ See [Evidence Signals](evidence_signals.md) for: - the current evidence inventory by module - the default shipped signal mapping -T Cell v1 only activates on `PAMP`. `DAMP` observations are still stored in -the T Cell observation table for auditing, but they do not advance the state -machine. +T Cell antigen recognition and state creation start only from `PAMP`. +`DAMP` observations are still stored in the T Cell observation table and are +used as weighted danger signals in co-stimulation and context calculations for +the same `profile.ip`, but they do not create cells or perform regex matching +by themselves. From 9dd8164499d29379fa7913f506722292811916f4 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:26:33 +0000 Subject: [PATCH 0121/1100] feat: update evidence type in AnomalyDetectionHTTPS to ANOMALOUS_FLOW --- modules/anomaly_detection_https/anomaly_detection_https.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/anomaly_detection_https/anomaly_detection_https.py b/modules/anomaly_detection_https/anomaly_detection_https.py index 5463dcdfc9..80f6201bf8 100644 --- a/modules/anomaly_detection_https/anomaly_detection_https.py +++ b/modules/anomaly_detection_https/anomaly_detection_https.py @@ -596,7 +596,7 @@ def emit_anomaly_evidence( src_port = None evidence = Evidence( - evidence_type=EvidenceType.MALICIOUS_FLOW, + evidence_type=EvidenceType.ANOMALOUS_FLOW, description=description, attacker=Attacker( direction=Direction.SRC, From 1661b8e446c8abf65a32bc574d24de78d6d0e5ac Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:26:40 +0000 Subject: [PATCH 0122/1100] feat: update evidence type in AnomalyDetectionHTTPS to ANOMALOUS_FLOW --- modules/anomaly_detection_https/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/anomaly_detection_https/README.md b/modules/anomaly_detection_https/README.md index 386e2ffd52..3790b079d3 100644 --- a/modules/anomaly_detection_https/README.md +++ b/modules/anomaly_detection_https/README.md @@ -480,7 +480,7 @@ Every detection (`flow_detection` and `hourly_detection`) is emitted as Slips Ev Evidence design: -- `evidence_type`: `MALICIOUS_FLOW` +- `evidence_type`: `ANOMALOUS_FLOW` - `method`: `STATISTICAL` - `attacker`: source host (`profileid` IP, direction `SRC`) - `victim`: best available destination context (`SNI` domain first, otherwise destination IP/domain) From 2a0aab471401b8faf4997dfbfb80b48b1294483a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:27:18 +0000 Subject: [PATCH 0123/1100] feat: include ANOMALOUS_FLOW in anomaly evidence types --- modules/regex_generator/regex_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/regex_generator/regex_generator.py b/modules/regex_generator/regex_generator.py index a255bfea27..8328ed2640 100644 --- a/modules/regex_generator/regex_generator.py +++ b/modules/regex_generator/regex_generator.py @@ -317,7 +317,7 @@ def _normalize_evidence_records(raw_evidence: dict) -> dict[str, dict]: @staticmethod def _count_anomaly_evidence(evidence_records: dict[str, dict]) -> int: - anomaly_evidence_types = {"MALICIOUS_FLOW"} + anomaly_evidence_types = {"ANOMALOUS_FLOW", "MALICIOUS_FLOW"} count = 0 for evidence in evidence_records.values(): evidence_type = str(evidence.get("evidence_type", "")) From 94996775671017e0b712aa974b600283eddfa543 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:27:26 +0000 Subject: [PATCH 0124/1100] feat: update T Cell module description to clarify DAMP integration and escalation process --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 090e8cd164..a19e9a4ac4 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Slips has a [config/slips.yaml](https://github.com/stratosphereips/StratosphereL * You can also specify whether to ```train``` or ```test``` the ML models * You can enable [popup notifications](https://stratospherelinuxips.readthedocs.io/en/develop/usage.html#popup-notifications) of evidence, enable [blocking](https://stratospherelinuxips.readthedocs.io/en/develop/usage.html#slips-permissions), [plug in your own zeek script](https://stratospherelinuxips.readthedocs.io/en/develop/usage.html#plug-in-a-zeek-script) and more. -* You can enable the `t_cell` section to consume `PAMP` evidence, match extracted antigens against accepted regexes, and escalate to blocking or memory while keeping all responder state in its own SQLite DB. +* The `t_cell` section is enabled by default so Slips can consume `PAMP` evidence, match extracted antigens against accepted regexes, and combine `DAMP` danger pressure into co-stimulation and context before escalating to blocking or memory, all while keeping responder state in its own SQLite DB. [More details about the config file options here]( https://stratospherelinuxips.readthedocs.io/en/develop/usage.html#modifying-the-configuration-file) @@ -188,7 +188,7 @@ Slips key features are: * **Integration with External Platforms**: Modules in Slips can look up IP addresses on external platforms such as VirusTotal and RiskIQ. * **Shared LLM Access**: Slips can expose configured LLM backends such as Ollama, OpenAI, and Anthropic to other modules through Redis channels. * **Pseudo-Random Regex Generation**: Slips can generate and validate pseudo-random regexes for DNS domains, URIs, filenames, TLS SNI, and certificate CN fields for later Zeek-side use. -* **Immune-Style T Cell Response**: Slips can consume centrally tagged `PAMP` evidence, correlate it with accepted regexes, and escalate to blocking or long-term memory through the new T Cell module. +* **Immune-Style T Cell Response**: Slips can consume centrally tagged `PAMP` evidence, correlate it with accepted regexes, and use `DAMP` danger pressure to refine co-stimulation and context before escalating to blocking or long-term memory through the T Cell module. * **Graphical User Interface**: Slips provides a console graphical user interface (Kalipso) and a web interface for displaying detection with graphs and tables. * **Peer-to-Peer (P2P) Module**: Slips includes a complex automatic system to find other peers in the network and share IoC data automatically in a balanced, trusted manner. The P2P module can be enabled as needed. * **Docker Implementation**: Running Slips through Docker on Linux systems is simplified, allowing real-time traffic analysis. From 75e73e4c8edd818a4fbffbd2d02523f893cbbcaa Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:27:35 +0000 Subject: [PATCH 0125/1100] feat: enhance T Cell module documentation to clarify antigen recognition and DAMP handling --- modules/t_cell/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index 1d044fa8f6..13a383d320 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -12,11 +12,16 @@ creates one T Cell per: Main behavior: -- only `PAMP` evidence activates the module in v1 +- only `PAMP` evidence starts antigen recognition and cell creation - antigens are extracted from evidence fields plus linked DNS/HTTP/SSL altflows - accepted regexes come from the existing RegexGenerator SQLite store +- stored `DAMP` observations raise the danger pressure used by + co-stimulation and context for the same `profile.ip` - co-stimulation and context scores decide whether the cell becomes tolerant, activates, requests containment, or stores memory +- state `1 - antigen-recognized` and state `3 - activated` can each wait for + at most one configured Slips time window before timing out to `2 - anergic` + or `0 - mature` - containment reuses the existing `new_blocking` payload shape - all T Cell state is stored in its own SQLite DB and log file From 461d8b29923060382c1ba7f3601e19ce3a44a108 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:27:42 +0000 Subject: [PATCH 0126/1100] feat: enhance T Cell module with improved logging and danger score calculations --- modules/t_cell/t_cell.py | 239 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 18 deletions(-) diff --git a/modules/t_cell/t_cell.py b/modules/t_cell/t_cell.py index 9e3377c2c7..d975eccdde 100644 --- a/modules/t_cell/t_cell.py +++ b/modules/t_cell/t_cell.py @@ -51,6 +51,9 @@ "related_pamps": 0.25, "danger": 0.40, } +LOG_VERBOSITY_SUMMARY = 1 +LOG_VERBOSITY_DECISIONS = 2 +LOG_VERBOSITY_DEBUG = 3 @dataclass(frozen=True) @@ -85,8 +88,9 @@ def as_dict(self) -> dict: class TCell(IModule): name = "T Cell" description = ( - "Immune-style responder that correlates PAMP evidence with regex " - "matches and escalates to blocking or memory." + "Immune-style responder that matches PAMP antigens to regexes and " + "uses both PAMP and DAMP danger pressure to escalate to blocking " + "or memory." ) authors = ["OpenAI Codex"] @@ -96,13 +100,16 @@ def init(self): self.enabled = False self.create_log_file = True self.log_colors = True + self.log_verbosity = LOG_VERBOSITY_SUMMARY self.log_file_path = os.path.join(self.output_dir, "t_cell.log") self.storage = None + self.state_wait_timeout_seconds = 3600.0 self.observation_retention_seconds = 604800 self.anergy_ttl_seconds = 21600 self.related_lookback_seconds = 3600 self.related_pamps_saturation = 5.0 self.danger_saturation = 2.5 + self.damp_danger_weight = 1.5 self.co_stimulation_threshold = 0.65 self.co_stimulation_weights = DEFAULT_COSTIM_WEIGHTS.copy() self.novelty_window_seconds = 86400 @@ -121,6 +128,13 @@ def read_configuration(self): self.enabled = conf.t_cell_enabled() self.create_log_file = conf.t_cell_create_log_file() self.log_colors = conf.t_cell_log_colors() + self.log_verbosity = conf.t_cell_log_verbosity() + try: + self.state_wait_timeout_seconds = float( + conf.get_tw_width_in_seconds() + ) + except Exception: + self.state_wait_timeout_seconds = 3600.0 self.observation_retention_seconds = ( conf.t_cell_observation_retention_seconds() ) @@ -128,6 +142,7 @@ def read_configuration(self): self.related_lookback_seconds = conf.t_cell_related_lookback_seconds() self.related_pamps_saturation = conf.t_cell_related_pamps_saturation() self.danger_saturation = conf.t_cell_danger_saturation() + self.damp_danger_weight = conf.t_cell_damp_danger_weight() self.co_stimulation_threshold = conf.t_cell_co_stimulation_threshold() self.co_stimulation_weights = self._normalize_weights( conf.t_cell_co_stimulation_weights() @@ -196,6 +211,20 @@ def _process_evidence_message(self, message: dict): now = time.time() antigens = self._extract_antigen_candidates(evidence) + if antigens: + self._log_event( + action="antigens_extracted", + state=None, + evidence=evidence, + details=( + "antigens=" + + ", ".join( + f"{candidate.regex_type}:{candidate.value}" + for candidate in antigens + ) + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) observation_id = self.storage.insert_observation( { "evidence_id": evidence.id, @@ -224,6 +253,7 @@ def _process_evidence_message(self, message: dict): state=None, evidence=evidence, details=f"signal={evidence.evidence_signal}", + verbosity=LOG_VERBOSITY_DECISIONS, ) self._prune_observations(now) return @@ -233,6 +263,11 @@ def _process_evidence_message(self, message: dict): action="no_antigen_extracted", state=None, evidence=evidence, + details=( + "no supported dns_domain/uri/filename/tls_sni/" + "certificate_cn values found" + ), + verbosity=LOG_VERBOSITY_DECISIONS, ) self._prune_observations(now) return @@ -269,6 +304,7 @@ def _process_candidate( evidence=evidence, cell=cell, details=f"until={cell['anergic_until']:.3f}", + verbosity=LOG_VERBOSITY_DECISIONS, ) return None @@ -317,6 +353,11 @@ def _process_candidate( state=cell["state"], evidence=evidence, cell=cell, + details=( + "cell already active; keeping current state without " + "a new regex match" + ), + verbosity=LOG_VERBOSITY_DECISIONS, ) return None @@ -362,6 +403,7 @@ def _process_candidate( ) if cell["state"] < STATE_ACTIVATED: + wait_elapsed = self._get_state_wait_elapsed(cell, now) if co_stimulation["value"] >= self.co_stimulation_threshold: cell = self._transition_cell( cell=cell, @@ -373,14 +415,60 @@ def _process_candidate( match=match, scores=co_stimulation, ) + elif ( + cell["state"] == STATE_ANTIGEN_RECOGNIZED + and self._state_wait_expired(cell, now) + ): + cell = self._transition_cell( + cell=cell, + to_state=STATE_ANERGIC, + reason="co_stimulation_timeout", + evidence=evidence, + observation_id=observation_id, + now=now, + match=match, + scores={ + **co_stimulation, + "elapsed": wait_elapsed, + "wait_limit": self.state_wait_timeout_seconds, + "anergic_until": now + self.anergy_ttl_seconds, + }, + extra_updates={ + "anergic_until": now + self.anergy_ttl_seconds, + }, + ) + return match else: self._log_event( - action="co_stimulation_pending", + action="waiting_for_co_stimulation", state=cell["state"], evidence=evidence, cell=cell, match=match, - metrics={"co_stimulation": co_stimulation["value"]}, + details=( + "score below threshold; keeping the cell in " + "antigen-recognized state until more corroborating " + "PAMPs arrive" + ), + metrics={ + "score": co_stimulation["value"], + "threshold": co_stimulation["threshold"], + "gap": max( + 0.0, + co_stimulation["threshold"] + - co_stimulation["value"], + ), + "confidence": co_stimulation["confidence"], + "related_pamps": co_stimulation["related_pamp_count"], + "related_score": co_stimulation["related_pamp_score"], + "danger_score": co_stimulation["profile_danger_score"], + "pamp_danger": co_stimulation["pamp_danger_score"], + "damp_danger": co_stimulation["damp_danger_score"], + "damp_weight": co_stimulation["damp_danger_weight"], + "elapsed": wait_elapsed, + "wait_limit": self.state_wait_timeout_seconds, + }, + verbosity=LOG_VERBOSITY_DECISIONS, ) return match @@ -434,19 +522,58 @@ def _process_candidate( cell=cell, match=match, metrics={"memory_score": context["memory_score"]}, + verbosity=LOG_VERBOSITY_SUMMARY, + ) + return match + + wait_elapsed = self._get_state_wait_elapsed(cell, now) + if ( + cell["state"] == STATE_ACTIVATED + and self._state_wait_expired(cell, now) + ): + self._transition_cell( + cell=cell, + to_state=STATE_MATURE, + reason="context_timeout", + evidence=evidence, + observation_id=observation_id, + now=now, + match=match, + scores={ + **context, + "elapsed": wait_elapsed, + "wait_limit": self.state_wait_timeout_seconds, + }, ) return match self._log_event( - action="context_hold", + action="waiting_for_context", state=cell["state"], evidence=evidence, cell=cell, match=match, + details=( + "context is not strong enough yet for effector or memory; " + "keeping the current state and reevaluating on future PAMPs" + ), metrics={ "effector_score": context["effector_score"], + "effector_threshold": context["effector_threshold"], "memory_score": context["memory_score"], + "memory_threshold": context["memory_threshold"], + "novelty_score": context["novelty_score"], + "related_pamps": context["recent_related_count"], + "recent_pamp_pressure": context["recent_pamp_pressure"], + "recent_damp_pressure": context["recent_damp_pressure"], + "previous_pamp_pressure": context["previous_pamp_pressure"], + "previous_damp_pressure": context["previous_damp_pressure"], + "damp_weight": context["damp_danger_weight"], + "trend_ratio": context["trend_ratio"], + "elapsed": wait_elapsed, + "wait_limit": self.state_wait_timeout_seconds, }, + verbosity=LOG_VERBOSITY_DECISIONS, ) return match @@ -538,6 +665,7 @@ def _transition_cell( cell=cell, match=match, metrics=scores, + verbosity=LOG_VERBOSITY_SUMMARY, ) return cell @@ -555,15 +683,20 @@ def _compute_co_stimulation( match: RegexMatch, now: float, ) -> dict: - observations = self.storage.get_recent_observations( + pamp_observations = self.storage.get_recent_observations( profile_ip, now - self.related_lookback_seconds, evidence_signal="PAMP", ) + damp_observations = self.storage.get_recent_observations( + profile_ip, + now - self.related_lookback_seconds, + evidence_signal="DAMP", + ) current_observation = self.storage.get_observation(observation_id) or {} confidence = float(current_observation.get("confidence", 0.0)) related_pamp_count = self._count_related_observations( - observations, + pamp_observations, candidate, match.regex_hash, exclude_observation_id=observation_id, @@ -571,9 +704,11 @@ def _compute_co_stimulation( related_pamp_score = self._clamp01( related_pamp_count / self.related_pamps_saturation ) - profile_danger_score = self._normalize_danger( - self._sum_danger(observations) + danger_scores = self._compute_danger_scores( + pamp_observations, + damp_observations, ) + profile_danger_score = danger_scores["combined_score"] value = ( self.co_stimulation_weights["confidence"] * confidence + self.co_stimulation_weights["related_pamps"] * related_pamp_score @@ -585,6 +720,9 @@ def _compute_co_stimulation( "related_pamp_count": related_pamp_count, "related_pamp_score": related_pamp_score, "profile_danger_score": profile_danger_score, + "pamp_danger_score": danger_scores["pamp_score"], + "damp_danger_score": danger_scores["damp_score"], + "damp_danger_weight": self.damp_danger_weight, "threshold": self.co_stimulation_threshold, } @@ -599,19 +737,30 @@ def _compute_context_signals( recent_start = now - self.context_recent_window_seconds previous_start = now - (2 * self.context_recent_window_seconds) - recent_observations = self.storage.get_recent_observations( + recent_pamp_observations = self.storage.get_recent_observations( profile_ip, recent_start, evidence_signal="PAMP", ) - previous_observations = self.storage.get_recent_observations( + recent_damp_observations = self.storage.get_recent_observations( + profile_ip, + recent_start, + evidence_signal="DAMP", + ) + previous_pamp_observations = self.storage.get_recent_observations( profile_ip, previous_start, until_ts=recent_start, evidence_signal="PAMP", ) + previous_damp_observations = self.storage.get_recent_observations( + profile_ip, + previous_start, + until_ts=recent_start, + evidence_signal="DAMP", + ) recent_related_count = self._count_related_observations( - recent_observations, + recent_pamp_observations, candidate, match.regex_hash, exclude_observation_id=observation_id, @@ -619,12 +768,16 @@ def _compute_context_signals( recent_related_score = self._clamp01( recent_related_count / self.related_pamps_saturation ) - recent_pressure = self._normalize_danger( - self._sum_danger(recent_observations) + recent_danger = self._compute_danger_scores( + recent_pamp_observations, + recent_damp_observations, ) - previous_pressure = self._normalize_danger( - self._sum_danger(previous_observations) + previous_danger = self._compute_danger_scores( + previous_pamp_observations, + previous_damp_observations, ) + recent_pressure = recent_danger["combined_score"] + previous_pressure = previous_danger["combined_score"] trend_ratio = recent_pressure / max(previous_pressure, 0.01) novelty_score = ( 1.0 @@ -663,6 +816,11 @@ def _compute_context_signals( "novelty_score": novelty_score, "recent_pressure": recent_pressure, "previous_pressure": previous_pressure, + "recent_pamp_pressure": recent_danger["pamp_score"], + "recent_damp_pressure": recent_danger["damp_score"], + "previous_pamp_pressure": previous_danger["pamp_score"], + "previous_damp_pressure": previous_danger["damp_score"], + "damp_danger_weight": self.damp_danger_weight, "trend_ratio": trend_ratio, "recent_related_count": recent_related_count, "recent_related_score": recent_related_score, @@ -710,6 +868,11 @@ def _apply_effector( cell=cell, match=match, metrics={"cooldown_until": cooldown_until}, + details=( + "effector already fired recently for this cell; " + "suppressing repeated blocking" + ), + verbosity=LOG_VERBOSITY_DECISIONS, ) return @@ -737,6 +900,7 @@ def _apply_effector( cell=cell, match=match, metrics={"effector_score": context["effector_score"]}, + verbosity=LOG_VERBOSITY_SUMMARY, ) return @@ -749,6 +913,7 @@ def _apply_effector( match=match, details=json.dumps(blocking_data, sort_keys=True), metrics={"effector_score": context["effector_score"]}, + verbosity=LOG_VERBOSITY_SUMMARY, ) return @@ -759,6 +924,8 @@ def _apply_effector( cell=cell, match=match, metrics={"effector_score": context["effector_score"]}, + details="blocking modules are not running and simulation is disabled", + verbosity=LOG_VERBOSITY_SUMMARY, ) def _store_memory( @@ -951,6 +1118,20 @@ def _sum_danger(observations: list[dict]) -> float: for obs in observations ) + def _compute_danger_scores( + self, + pamp_observations: list[dict], + damp_observations: list[dict], + ) -> dict: + pamp_raw = self._sum_danger(pamp_observations) + damp_raw = self._sum_danger(damp_observations) + combined_raw = pamp_raw + (self.damp_danger_weight * damp_raw) + return { + "pamp_score": self._normalize_danger(pamp_raw), + "damp_score": self._normalize_danger(damp_raw), + "combined_score": self._normalize_danger(combined_raw), + } + def _normalize_danger(self, raw_value: float) -> float: return self._clamp01(raw_value / self.danger_saturation) @@ -958,6 +1139,25 @@ def _normalize_danger(self, raw_value: float) -> float: def _clamp01(value: float) -> float: return max(0.0, min(1.0, float(value))) + @staticmethod + def _get_state_wait_elapsed(cell: dict, now: float) -> float: + start_ts = ( + cell.get("last_transition_at") + or cell.get("created_at") + or now + ) + try: + start_ts = float(start_ts) + except (TypeError, ValueError): + start_ts = now + return max(0.0, float(now) - start_ts) + + def _state_wait_expired(self, cell: dict, now: float) -> bool: + return ( + self._get_state_wait_elapsed(cell, now) + >= self.state_wait_timeout_seconds + ) + @staticmethod def _make_cell_key(profile_ip: str, regex_type: str, antigen_value: str) -> str: return f"{profile_ip}|{regex_type}|{antigen_value}" @@ -1046,13 +1246,16 @@ def _log_event( match: RegexMatch | None = None, details: str | None = None, metrics: dict | None = None, + verbosity: int = LOG_VERBOSITY_DECISIONS, ): + if verbosity > self.log_verbosity: + return parts = [ utils.convert_ts_format(time.time(), utils.alerts_format), - action, + f"action={action}", ] if state is not None: - parts.append(self._colorize_state(state)) + parts.append(f"state={self._colorize_state(state)}") if evidence: parts.append(f"evidence={evidence.evidence_type.name}") parts.append(f"eid={evidence.id}") From 4311bba1e0394eac46dd9f227e940a3c5587f3a8 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:27:48 +0000 Subject: [PATCH 0127/1100] feat: update T Cell configuration to enable by default and add log verbosity and DAMP danger weight settings --- slips_files/common/parsers/config_parser.py | 33 ++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index b00e078189..c02cb6b6f1 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -942,7 +942,7 @@ def regex_generator_seed_benign_samples(self) -> bool: return str(value).strip().lower() in ("true", "1", "yes", "on") def t_cell_enabled(self) -> bool: - value = self.read_configuration("t_cell", "enabled", False) + value = self.read_configuration("t_cell", "enabled", True) if isinstance(value, bool): return value return str(value).strip().lower() in ("true", "1", "yes", "on") @@ -959,6 +959,29 @@ def t_cell_log_colors(self) -> bool: return value return str(value).strip().lower() in ("true", "1", "yes", "on") + def t_cell_log_verbosity(self) -> int: + value = self.read_configuration("t_cell", "log_verbosity", 1) + if isinstance(value, bool): + return 1 + if isinstance(value, (int, float)): + value = int(value) + else: + normalized = str(value).strip().lower() + named_levels = { + "summary": 1, + "decision": 2, + "decisions": 2, + "debug": 3, + } + if normalized in named_levels: + value = named_levels[normalized] + else: + try: + value = int(normalized) + except (TypeError, ValueError): + value = 1 + return max(1, min(3, int(value))) + def t_cell_store_dir(self) -> str: value = self.read_configuration("t_cell", "store_dir", "output/t_cell") if not isinstance(value, str) or not value.strip(): @@ -1019,6 +1042,14 @@ def t_cell_danger_saturation(self) -> float: value = 2.5 return max(0.01, value) + def t_cell_damp_danger_weight(self) -> float: + value = self.read_configuration("t_cell", "damp_danger_weight", 1.5) + try: + value = float(value) + except (TypeError, ValueError): + value = 1.5 + return max(0.0, value) + def t_cell_co_stimulation_threshold(self) -> float: value = self.read_configuration( "t_cell", "co_stimulation_threshold", 0.65 From cb54e7fe6c172ee45ed53f559ddf4701aa6d74d7 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:27:55 +0000 Subject: [PATCH 0128/1100] feat: add ANOMALOUS_FLOW to EvidenceType enum for enhanced anomaly detection --- slips_files/core/structures/evidence.py | 1 + 1 file changed, 1 insertion(+) diff --git a/slips_files/core/structures/evidence.py b/slips_files/core/structures/evidence.py index 7952a48564..8a84de9691 100644 --- a/slips_files/core/structures/evidence.py +++ b/slips_files/core/structures/evidence.py @@ -85,6 +85,7 @@ class EvidenceType(Enum): BAD_SMTP_LOGIN = auto() SMTP_LOGIN_BRUTEFORCE = auto() MALICIOUS_SSL_CERT = auto() + ANOMALOUS_FLOW = auto() MALICIOUS_FLOW = auto() SUSPICIOUS_USER_AGENT = auto() EMPTY_CONNECTIONS = auto() From 5af2fb7cbbca7c4133638113da5f1a4253d30d89 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:28:04 +0000 Subject: [PATCH 0129/1100] feat: update evidence signal overrides to include ANOMALOUS_FLOW and enhance T Cell configuration --- tests/module_factory.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/module_factory.py b/tests/module_factory.py index 1ce22faa50..0c3336c74a 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -70,7 +70,10 @@ def create_db_manager_obj( conf.disabled_detections = Mock(return_value=[]) conf.evidence_signal_default = Mock(return_value="PAMP") conf.evidence_signal_overrides = Mock( - return_value={"MALICIOUS_FLOW": "DAMP"} + return_value={ + "ANOMALOUS_FLOW": "DAMP", + "MALICIOUS_FLOW": "DAMP", + } ) conf.get_tw_width_as_float = Mock(return_value=3600.0) conf.get_tw_width_in_seconds = Mock(return_value=3600) @@ -254,6 +257,8 @@ def create_t_cell_obj(self, mock_db): conf.t_cell_enabled = Mock(return_value=True) conf.t_cell_create_log_file = Mock(return_value=True) conf.t_cell_log_colors = Mock(return_value=True) + conf.t_cell_log_verbosity = Mock(return_value=1) + conf.get_tw_width_in_seconds = Mock(return_value=3600.0) conf.t_cell_store_dir = Mock(return_value="dummy_output_dir/t_cell") conf.t_cell_persistent_store_dir = Mock(return_value="") conf.t_cell_observation_retention_seconds = Mock(return_value=604800) @@ -261,6 +266,7 @@ def create_t_cell_obj(self, mock_db): conf.t_cell_related_lookback_seconds = Mock(return_value=3600) conf.t_cell_related_pamps_saturation = Mock(return_value=5.0) conf.t_cell_danger_saturation = Mock(return_value=2.5) + conf.t_cell_damp_danger_weight = Mock(return_value=1.5) conf.t_cell_co_stimulation_threshold = Mock(return_value=0.65) conf.t_cell_co_stimulation_weights = Mock( return_value={ @@ -1130,7 +1136,8 @@ def create_alert_handler_obj(self): alert_handler.extended_ttl = 3600 alert_handler.default_evidence_signal = "PAMP" alert_handler.evidence_signal_overrides = { - "MALICIOUS_FLOW": "DAMP" + "ANOMALOUS_FLOW": "DAMP", + "MALICIOUS_FLOW": "DAMP", } alert_handler.set_profileid_field = Mock() return alert_handler From 85df8de78d202ad527726e03517361be0e472cac Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:28:13 +0000 Subject: [PATCH 0130/1100] feat: add ANOMALOUS_FLOW to DisabledAlerts for enhanced detection capabilities --- tests/integration/config/fides_config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/config/fides_config.yaml b/tests/integration/config/fides_config.yaml index 52d748df29..d81059c007 100644 --- a/tests/integration/config/fides_config.yaml +++ b/tests/integration/config/fides_config.yaml @@ -353,7 +353,7 @@ DisabledAlerts: # MULTIPLE_RECONNECTION_ATTEMPTS, CONNECTION_TO_MULTIPLE_PORTS, HIGH_ENTROPY_DNS_ANSWER, # INVALID_DNS_RESOLUTION, PORT_0_CONNECTION, MALICIOUS_JA3, MALICIOUS_JA3S, # DATA_UPLOAD, BAD_SMTP_LOGIN, SMTP_LOGIN_BRUTEFORCE, MALICIOUS_SSL_CERT, - # MALICIOUS_FLOW, SUSPICIOUS_USER_AGENT, EMPTY_CONNECTIONS, INCOMPATIBLE_USER_AGENT, + # ANOMALOUS_FLOW, MALICIOUS_FLOW, SUSPICIOUS_USER_AGENT, EMPTY_CONNECTIONS, INCOMPATIBLE_USER_AGENT, # EXECUTABLE_MIME_TYPE, MULTIPLE_USER_AGENT, HTTP_TRAFFIC, MALICIOUS_JARM, # NETWORK_GPS_LOCATION_LEAKED, ICMP_TIMESTAMP_SCAN, ICMP_ADDRESS_SCAN, # ICMP_ADDRESS_MASK_SCAN, DHCP_SCAN, MALICIOUS_IP_FROM_P2P_NETWORK, P2P_REPORT, From d785fddb0504bcb163ea613fcafc594f61b808da Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:28:26 +0000 Subject: [PATCH 0131/1100] feat: add unit tests for AnomalyDetectionHTTPS to validate ANOMALOUS_FLOW evidence emission --- .../test_anomaly_detection_https.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/unit/modules/anomaly_detection_https/test_anomaly_detection_https.py diff --git a/tests/unit/modules/anomaly_detection_https/test_anomaly_detection_https.py b/tests/unit/modules/anomaly_detection_https/test_anomaly_detection_https.py new file mode 100644 index 0000000000..957a31aefb --- /dev/null +++ b/tests/unit/modules/anomaly_detection_https/test_anomaly_detection_https.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2026 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +from unittest.mock import Mock, patch + +from modules.anomaly_detection_https.anomaly_detection_https import ( + AnomalyDetectionHTTPS, +) +from slips_files.core.structures.evidence import EvidenceType + + +def _create_https_anomaly_module(tmp_path): + conf = Mock() + conf.https_anomaly_training_hours = Mock(return_value=24) + conf.https_anomaly_hourly_zscore_thr = Mock(return_value=3.0) + conf.https_anomaly_flow_zscore_thr = Mock(return_value=3.5) + conf.https_anomaly_adapt_score_thr = Mock(return_value=2.0) + conf.https_anomaly_baseline_alpha = Mock(return_value=0.1) + conf.https_anomaly_drift_alpha = Mock(return_value=0.05) + conf.https_anomaly_suspicious_alpha = Mock(return_value=0.005) + conf.https_anomaly_min_baseline_points = Mock(return_value=6) + conf.https_anomaly_max_small_flow_anomalies = Mock(return_value=1) + conf.https_anomaly_ja3_min_variants_per_server = Mock(return_value=3) + conf.https_anomaly_use_adwin_drift = Mock(return_value=False) + conf.https_anomaly_adwin_delta = Mock(return_value=0.002) + conf.https_anomaly_adwin_clock = Mock(return_value=32) + conf.https_anomaly_adwin_grace_period = Mock(return_value=10) + conf.https_anomaly_adwin_min_window_length = Mock(return_value=5) + conf.https_anomaly_log_verbosity = Mock(return_value=0) + + db = Mock() + db.subscribe.return_value = Mock() + db.client_setname = Mock() + db.set_evidence = Mock() + db.close_sqlite = Mock() + + args = Mock() + args.interface = None + args.access_point = False + + with ( + patch( + "slips_files.common.abstracts.imodule.DBManager", + return_value=db, + ), + patch( + "modules.anomaly_detection_https.anomaly_detection_https.ConfigParser", + return_value=conf, + ), + ): + module = AnomalyDetectionHTTPS( + logger=Mock(), + output_dir=str(tmp_path), + redis_port=6379, + termination_event=Mock(), + slips_args=args, + conf=conf, + ppid=12345, + bloom_filters_manager=Mock(), + ) + module.print = Mock() + return module, db + + +def test_emit_anomaly_evidence_uses_anomalous_flow_type(tmp_path): + module, db = _create_https_anomaly_module(tmp_path) + + module.emit_anomaly_evidence( + profileid="profile_192.168.1.20", + twid_number=7, + traffic_ts=1_700_000_000.0, + uid="uid-1", + confidence={"level": "high", "score": 0.8}, + reasons=[{"feature": "new_server", "value": "bad.example.com"}], + kind="flow", + server="bad.example.com", + sni="bad.example.com", + daddr="93.184.216.34", + ) + + evidence = db.set_evidence.call_args.args[0] + assert evidence.evidence_type == EvidenceType.ANOMALOUS_FLOW + assert evidence.description.startswith("HTTPS anomaly:") + assert evidence.profile.ip == "192.168.1.20" From aafc922b3b72cb551bbf7a7968e6a93e7491cd26 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:28:37 +0000 Subject: [PATCH 0132/1100] feat: add test for counting ANOMALOUS_FLOW evidence in RegexGenerator --- .../modules/regex_generator/test_regex_generator.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/modules/regex_generator/test_regex_generator.py b/tests/unit/modules/regex_generator/test_regex_generator.py index ef1fc6d754..8169004a33 100644 --- a/tests/unit/modules/regex_generator/test_regex_generator.py +++ b/tests/unit/modules/regex_generator/test_regex_generator.py @@ -7,6 +7,7 @@ from modules.regex_generator.regex_generator import ( PROMPT_VERSION, + RegexGenerator, SYSTEM_PROMPT, TYPE_PROMPTS, ) @@ -354,6 +355,17 @@ def test_dirty_host_tw_does_not_import_runtime_benign_strings(tmp_path, mocker): regex_generator.shutdown_gracefully() +def test_count_anomaly_evidence_counts_anomalous_flow(): + count = RegexGenerator._count_anomaly_evidence( + { + "ev-1": {"evidence_type": "ANOMALOUS_FLOW", "description": ""}, + "ev-2": {"evidence_type": "SSH_SUCCESSFUL", "description": ""}, + } + ) + + assert count == 1 + + def test_build_prompt_messages_uses_type_specific_prompt(tmp_path): regex_generator = ModuleFactory().create_regex_generator_obj( store_dir=str(tmp_path / "regex_generator") From 23535cf077118033e9c450cf9084da891f289711 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:28:45 +0000 Subject: [PATCH 0133/1100] feat: add tests for T Cell co-stimulation and context timeout scenarios --- tests/unit/modules/t_cell/test_t_cell.py | 248 ++++++++++++++++++++++- 1 file changed, 246 insertions(+), 2 deletions(-) diff --git a/tests/unit/modules/t_cell/test_t_cell.py b/tests/unit/modules/t_cell/test_t_cell.py index 8e0f4ebfa8..1b73898a26 100644 --- a/tests/unit/modules/t_cell/test_t_cell.py +++ b/tests/unit/modules/t_cell/test_t_cell.py @@ -4,9 +4,11 @@ from unittest.mock import Mock, patch from modules.t_cell.t_cell import ( + STATE_ACTIVATED, STATE_ANERGIC, STATE_ANTIGEN_RECOGNIZED, STATE_EFFECTOR, + STATE_MATURE, STATE_MEMORY, AntigenCandidate, RegexMatch, @@ -39,7 +41,7 @@ def _build_storage(tmp_path): return TCellStorage(Mock(), conf, str(tmp_path), 12345) -def _prepare_t_cell(tmp_path): +def _prepare_t_cell(tmp_path, log_verbosity: int = 3): t_cell = ModuleFactory().create_t_cell_obj() t_cell.output_dir = str(tmp_path) t_cell.log_file_path = str(tmp_path / "t_cell.log") @@ -47,6 +49,7 @@ def _prepare_t_cell(tmp_path): t_cell.db.get_t_cell_storage.return_value = storage with patch("modules.t_cell.t_cell.utils.drop_root_privs_permanently"): assert t_cell.pre_main() is False + t_cell.log_verbosity = log_verbosity return t_cell, storage @@ -99,12 +102,13 @@ def _insert_observation( threat_level_value: float, threat_level: str = "high", matched_regexes: list[dict] | None = None, + evidence_signal: str = "PAMP", ): return storage.insert_observation( { "evidence_id": evidence_id, "evidence_type": "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", - "evidence_signal": "PAMP", + "evidence_signal": evidence_signal, "profile_ip": profile_ip, "timewindow_number": 1, "timestamp": TEST_TS, @@ -269,6 +273,45 @@ def test_t_cell_no_match_becomes_anergic_and_expires(tmp_path): assert cell["state"] == STATE_ANTIGEN_RECOGNIZED +def test_t_cell_co_stimulation_times_out_after_one_tw(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + t_cell.state_wait_timeout_seconds = 100.0 + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "timeout-regex" + ) + + first = _build_evidence( + "costim-timeout-1", + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=0.1, + ) + second = _build_evidence( + "costim-timeout-2", + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=0.1, + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=5_000.0): + t_cell._process_evidence_message(_message_for(first)) + with patch("modules.t_cell.t_cell.time.time", return_value=5_101.0): + t_cell._process_evidence_message(_message_for(second)) + + cell = storage.get_all_cells()[0] + transitions = [ + transition["reason"] + for transition in storage.get_transitions(cell["cell_key"]) + ] + assert "co_stimulation_timeout" in transitions + assert cell["state"] == STATE_ANERGIC + assert cell["anergic_until"] == 5_101.0 + t_cell.anergy_ttl_seconds + + def test_find_best_regex_match_prefers_specificity_and_newest(tmp_path): t_cell, _ = _prepare_t_cell(tmp_path) t_cell.db.get_generated_regexes.return_value = [ @@ -448,6 +491,206 @@ def test_t_cell_moves_to_memory_and_stores_context(tmp_path): assert any(memory["cell_key"] == cell["cell_key"] for memory in memories) +def test_t_cell_context_times_out_after_one_tw(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + t_cell.state_wait_timeout_seconds = 100.0 + profile_ip = "10.0.0.63" + evidence_1 = _build_evidence("context-timeout-1", profile_ip=profile_ip, uids=["dns-1"]) + evidence_2 = _build_evidence("context-timeout-2", profile_ip=profile_ip, uids=["dns-1"]) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "context-timeout-regex" + ) + for index in range(4): + _insert_observation( + storage=storage, + evidence_id=f"danger-{index}", + profile_ip=profile_ip, + antigens=[ + { + "regex_type": "dns_domain", + "value": f"other-{index}.example.com", + } + ], + observed_at=5_800.0 - index, + confidence=1.0, + threat_level_value=0.8, + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=6_000.0): + t_cell._process_evidence_message(_message_for(evidence_1)) + with patch("modules.t_cell.t_cell.time.time", return_value=6_101.0): + t_cell._process_evidence_message(_message_for(evidence_2)) + + cell = storage.get_all_cells()[0] + transitions = [ + transition["reason"] + for transition in storage.get_transitions(cell["cell_key"]) + ] + assert "context_timeout" in transitions + assert cell["state"] == STATE_MATURE + + +def test_t_cell_damp_observations_raise_co_stimulation(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path) + fixed_now = 14_000.0 + profile_ip = "10.0.0.64" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence = _build_evidence( + "damp-costim-1", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.MEDIUM, + confidence=0.7, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-costim-regex" + ) + t_cell.db.get_pid_of.return_value = None + _seed_recent_related_observations( + storage, + profile_ip, + antigen, + fixed_now, + count=2, + confidence=0.5, + threat_level_value=0.5, + ) + _insert_observation( + storage=storage, + evidence_id="damp-pressure-1", + profile_ip=profile_ip, + antigens=[], + observed_at=fixed_now - 30, + confidence=1.0, + threat_level_value=1.0, + threat_level="critical", + evidence_signal="DAMP", + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_ACTIVATED + assert any( + transition["reason"] == "co_stimulation_threshold_met" + and transition["scores"]["damp_danger_score"] > 0 + for transition in transitions + ) + assert t_cell.db.publish.call_count == 0 + + +def test_t_cell_damp_observations_raise_context_pressure(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path) + fixed_now = 15_000.0 + profile_ip = "10.0.0.65" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence = _build_evidence( + "damp-context-1", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-context-regex" + ) + t_cell.db.get_pid_of.side_effect = ( + lambda name: 123 if name == "Blocking" else None + ) + _seed_recent_related_observations( + storage, + profile_ip, + antigen, + fixed_now, + count=4, + confidence=1.0, + threat_level_value=0.2, + age_seconds=120, + ) + _insert_observation( + storage=storage, + evidence_id="damp-pressure-2", + profile_ip=profile_ip, + antigens=[], + observed_at=fixed_now - 20, + confidence=1.0, + threat_level_value=1.0, + threat_level="critical", + evidence_signal="DAMP", + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_EFFECTOR + assert any( + transition["reason"] == "context_effector" + and transition["scores"]["recent_damp_pressure"] > 0 + for transition in transitions + ) + assert t_cell.db.publish.call_count == 1 + + +def test_t_cell_summary_log_hides_waiting_for_co_stimulation(tmp_path): + t_cell, _ = _prepare_t_cell(tmp_path, log_verbosity=1) + evidence = _build_evidence("pending-1", uids=["dns-1"]) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "pending-regex" + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=13_000.0): + t_cell._process_evidence_message(_message_for(evidence)) + + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + log_contents = log_file.read() + + assert "action=antigen_recognized" in log_contents + assert "waiting_for_co_stimulation" not in log_contents + + +def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): + t_cell, _ = _prepare_t_cell(tmp_path, log_verbosity=2) + evidence = _build_evidence("pending-2", uids=["dns-1"]) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "pending-regex" + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=13_500.0): + t_cell._process_evidence_message(_message_for(evidence)) + + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + log_contents = log_file.read() + + assert "waiting_for_co_stimulation" in log_contents + assert "score=" in log_contents + assert "threshold=" in log_contents + assert "related_pamps=" in log_contents + + def test_t_cell_log_file_contains_color_codes(tmp_path): t_cell, _ = _prepare_t_cell(tmp_path) evidence = _build_evidence("log-1") @@ -457,6 +700,7 @@ def test_t_cell_log_file_contains_color_codes(tmp_path): state=STATE_EFFECTOR, evidence=evidence, metrics={"score": 0.95}, + verbosity=3, ) with open(t_cell.log_file_path, encoding="utf-8") as log_file: From 7b8f258d83c8814412e697cd6b97f0ad82d51be9 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:28:56 +0000 Subject: [PATCH 0134/1100] feat: enhance evidence signal overrides and T Cell configuration defaults --- tests/unit/slips_files/common/test_config_parser.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/unit/slips_files/common/test_config_parser.py b/tests/unit/slips_files/common/test_config_parser.py index ea8ba83b64..be28044f9c 100644 --- a/tests/unit/slips_files/common/test_config_parser.py +++ b/tests/unit/slips_files/common/test_config_parser.py @@ -16,6 +16,7 @@ def test_evidence_signal_overrides_sanitizes_values(): parser.config = { "EvidenceSignals": { "overrides": { + "anomalous_flow": "DAMP", "malicious_flow": "damp", "ssh_successful": "PAMP", "bad_type": "invalid", @@ -25,6 +26,7 @@ def test_evidence_signal_overrides_sanitizes_values(): } assert parser.evidence_signal_overrides() == { + "ANOMALOUS_FLOW": "DAMP", "MALICIOUS_FLOW": "DAMP", "SSH_SUCCESSFUL": "PAMP", } @@ -34,9 +36,10 @@ def test_t_cell_config_defaults(): parser = ConfigParser.__new__(ConfigParser) parser.config = {} - assert parser.t_cell_enabled() is False + assert parser.t_cell_enabled() is True assert parser.t_cell_create_log_file() is True assert parser.t_cell_log_colors() is True + assert parser.t_cell_log_verbosity() == 1 assert parser.t_cell_store_dir() == "output/t_cell" assert parser.t_cell_persistent_store_dir() == "" assert parser.t_cell_observation_retention_seconds() == 604800 @@ -44,6 +47,7 @@ def test_t_cell_config_defaults(): assert parser.t_cell_related_lookback_seconds() == 3600 assert parser.t_cell_related_pamps_saturation() == 5 assert parser.t_cell_danger_saturation() == 2.5 + assert parser.t_cell_damp_danger_weight() == 1.5 assert parser.t_cell_co_stimulation_threshold() == 0.65 assert parser.t_cell_co_stimulation_weights() == { "confidence": 0.35, @@ -68,6 +72,7 @@ def test_t_cell_config_sanitization(): "enabled": "true", "create_log_file": "false", "log_colors": "false", + "log_verbosity": "debug", "store_dir": "", "persistent_store_dir": " /tmp/tcell ", "observation_retention_seconds": "bad", @@ -75,6 +80,7 @@ def test_t_cell_config_sanitization(): "related_lookback_seconds": "bad", "related_pamps_saturation": "bad", "danger_saturation": 0, + "damp_danger_weight": -5, "co_stimulation_threshold": "bad", "co_stimulation_weights": { "confidence": 0, @@ -96,6 +102,7 @@ def test_t_cell_config_sanitization(): assert parser.t_cell_enabled() is True assert parser.t_cell_create_log_file() is False assert parser.t_cell_log_colors() is False + assert parser.t_cell_log_verbosity() == 3 assert parser.t_cell_store_dir() == "output/t_cell" assert parser.t_cell_persistent_store_dir() == "/tmp/tcell" assert parser.t_cell_observation_retention_seconds() == 604800 @@ -103,6 +110,7 @@ def test_t_cell_config_sanitization(): assert parser.t_cell_related_lookback_seconds() == 3600 assert parser.t_cell_related_pamps_saturation() == 5 assert parser.t_cell_danger_saturation() == 0.01 + assert parser.t_cell_damp_danger_weight() == 0.0 assert parser.t_cell_co_stimulation_threshold() == 0.65 assert parser.t_cell_co_stimulation_weights() == { "confidence": 0.35, From eb9d7d8bd3ebfea4f79d3e3928691c85334fcd6c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:29:06 +0000 Subject: [PATCH 0135/1100] feat: add analyze_incidents script for processing alerts and Zeek logs --- .../logs_analysis/analyze_incidents.py | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 slips_files/logs_analysis/analyze_incidents.py diff --git a/slips_files/logs_analysis/analyze_incidents.py b/slips_files/logs_analysis/analyze_incidents.py new file mode 100644 index 0000000000..3cfae92bd5 --- /dev/null +++ b/slips_files/logs_analysis/analyze_incidents.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +import json +import sys +from pathlib import Path +import glob + +# --- Colors --- +RESET = "\033[0m" +BOLD = "\033[1m" +CYAN = "\033[96m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +RED = "\033[91m" +MAGENTA = "\033[95m" +GRAY = "\033[90m" +BLUE = "\033[94m" + +def usage(): + print(f"Usage: {sys.argv[0]} [--debug]") + print(" : 'incident' or 'event'") + sys.exit(1) + +def load_jsonl(path): + """Yield parsed JSON objects (line-delimited JSON).""" + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + continue + +def load_zeek_log(path): + """Load a Zeek log file (JSON or TSV) and index by UID or UIDs.""" + flows = {} + headers = set() + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + + flow = None + + # JSON log (modern Zeek format) + if line.startswith("{"): + try: + flow = json.loads(line) + except json.JSONDecodeError: + continue + else: + # TSV fallback + if line.startswith("#fields"): + headers.update(line.split()[1:]) + continue + parts = line.split("\t") + if headers and len(parts) == len(headers): + flow = dict(zip(list(headers), parts)) + + if not flow: + continue + + headers.update(flow.keys()) + + # Normalize UID handling + uids = [] + if "uid" in flow: + uids = [flow["uid"]] + elif "uids" in flow and isinstance(flow["uids"], list): + uids = flow["uids"] + + for uid in uids: + if uid: + flows.setdefault(uid.strip(), []).append(flow) + except Exception as e: + print(f"{RED}Error parsing {path}:{RESET} {e}") + return flows, sorted(headers) + +def parse_note_uids(note_str): + """Extract UIDs from the Note JSON string inside an Event.""" + if not note_str: + return [] + try: + note = json.loads(note_str) + if isinstance(note, str): + note = json.loads(note) + if isinstance(note, dict): + return note.get("uids", []) + except Exception: + pass + return [] + +def show_event(event, log_files, logs_data, debug=False): + """Show an event and all matching Zeek flows with all columns per log type.""" + eid = event.get("ID") + desc = event.get("Description", "").replace("\n", " ").strip() + sev = event.get("Severity", "Unknown") + src_ips = ", ".join(sv.get("IP") for sv in event.get("Source", []) if sv.get("IP")) + uids = parse_note_uids(event.get("Note", "{}")) + + sev_color = {"Low": GREEN, "Medium": YELLOW, "High": RED, "Critical": MAGENTA}.get(sev, RESET) + + print(f"{BOLD}{CYAN}Event:{RESET} {eid}") + print(f" {BOLD}Severity:{RESET} {sev_color}{sev}{RESET}") + print(f" {BOLD}Source IP(s):{RESET} {src_ips}") + print(f" {BOLD}Description:{RESET} {desc}") + print(f" {BOLD}UIDs from Note:{RESET} {uids if uids else '(none)'}") + + if not uids: + print(f" {GRAY}(no flow UIDs in Note){RESET}") + print(f"{GRAY}{'-'*120}{RESET}") + return + + # --- Search all Zeek logs for these UIDs --- + matched = [] + for lf in log_files: + for uid in uids: + if uid in logs_data[lf]["flows"]: + for row in logs_data[lf]["flows"][uid]: + matched.append((row, Path(lf).name)) + + if not matched: + print(f" {GRAY}(no matching flows found in Zeek logs){RESET}") + print(f"{GRAY}{'-'*120}{RESET}") + return + + print(f"\n {BOLD}{MAGENTA}Flows found:{RESET} {len(matched)}") + print(f"{GRAY}{'-'*120}{RESET}") + + # Group by log file + by_file = {} + for flow, fname in matched: + by_file.setdefault(fname, []).append(flow) + + for fname, flows in by_file.items(): + print(f"{BOLD}{BLUE}{fname}:{RESET}") + all_fields = sorted({k for f in flows for k in f.keys()}) + widths = {k: len(k) for k in all_fields} + for f in flows: + for k in all_fields: + val = str(f.get(k, "-")) + if len(val) > widths[k]: + widths[k] = min(len(val), 80) # avoid super wide columns + + # Header + header_line = " " + " ".join(f"{CYAN}{BOLD}{h.ljust(widths[h])}{RESET}" for h in all_fields) + print(header_line) + print(" " + "-" * (len(header_line) - 2)) + + # Rows + for f in sorted(flows, key=lambda x: float(x.get("ts", 0)) if "ts" in x else 0): + row = " " + " ".join( + str(f.get(h, "-"))[:widths[h]].ljust(widths[h]) for h in all_fields + ) + print(row) + print(f"{GRAY}{'-'*120}{RESET}") + +def main(): + if len(sys.argv) < 5: + usage() + + alerts_file = Path(sys.argv[1]) + mode = sys.argv[2].lower() + target_id = sys.argv[3] + zeek_folder = Path(sys.argv[4]) + debug = "--debug" in sys.argv + + if mode not in ("incident", "event"): + usage() + + if not alerts_file.exists(): + sys.exit(f"{RED}Alerts file not found:{RESET} {alerts_file}") + if not zeek_folder.exists(): + sys.exit(f"{RED}Zeek folder not found:{RESET} {zeek_folder}") + + # --- Load alerts --- + incidents, events = [], [] + for obj in load_jsonl(alerts_file): + if obj.get("Status") == "Incident": + incidents.append(obj) + elif obj.get("Status") == "Event": + events.append(obj) + + # --- Load all .log files (recursively) --- + log_files = sorted(glob.glob(str(zeek_folder / "**" / "*.log"), recursive=True)) + if not log_files: + sys.exit(f"{RED}No .log files found in folder:{RESET} {zeek_folder}") + + logs_data = {} + for lf in log_files: + flows, headers = load_zeek_log(lf) + logs_data[lf] = {"flows": flows, "headers": headers} + if debug: + print(f"{GRAY}Loaded {len(flows)} UIDs from {Path(lf).name}{RESET}") + if headers: + print(f" {BOLD}{BLUE}Columns ({len(headers)}):{RESET} {', '.join(sorted(headers))}\n") + + # --- Main logic --- + if mode == "incident": + incident = next((i for i in incidents if i.get("ID") == target_id), None) + if not incident: + sys.exit(f"{RED}Incident {target_id} not found.{RESET}") + + correl_ids = set(incident.get("CorrelID", [])) + related_events = [e for e in events if e.get("ID") in correl_ids] + + print(f"\n{BOLD}{CYAN}Incident:{RESET} {target_id}") + print(f"{GRAY}{'-'*120}{RESET}") + + if not related_events: + print(f"{YELLOW}(No related events found){RESET}") + return + + for ev in related_events: + show_event(ev, log_files, logs_data, debug=debug) + + elif mode == "event": + event = next((e for e in events if e.get("ID") == target_id), None) + if not event: + sys.exit(f"{RED}Event {target_id} not found.{RESET}") + + print(f"\n{BOLD}{CYAN}Analyzing single Event:{RESET} {target_id}") + print(f"{GRAY}{'-'*120}{RESET}") + show_event(event, log_files, logs_data, debug=debug) + +if __name__ == "__main__": + main() From 002749b4d2b41b8cafecf9b37b84ff3f1dc3694a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 19:29:16 +0000 Subject: [PATCH 0136/1100] feat: add ANOMALOUS_FLOW evidence type with corresponding DAMP signal --- .../slips_files/core/database/redis_db/test_alert_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/slips_files/core/database/redis_db/test_alert_handler.py b/tests/unit/slips_files/core/database/redis_db/test_alert_handler.py index 7966873b90..69102837f0 100644 --- a/tests/unit/slips_files/core/database/redis_db/test_alert_handler.py +++ b/tests/unit/slips_files/core/database/redis_db/test_alert_handler.py @@ -300,6 +300,7 @@ def test_init_evidence_number(initial_value, expected_value): @pytest.mark.parametrize( "evidence_type, expected_signal", [ + (EvidenceType.ANOMALOUS_FLOW, EvidenceSignal.DAMP), (EvidenceType.SSH_SUCCESSFUL, EvidenceSignal.PAMP), (EvidenceType.MALICIOUS_FLOW, EvidenceSignal.DAMP), ], From f8af8e81f5504756008d7340beb0a5ca22554172 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 21:28:42 +0000 Subject: [PATCH 0137/1100] feat: add SSH_SUCCESSFUL evidence type with corresponding DAMP signal --- config/slips.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/slips.yaml b/config/slips.yaml index 9cdf70dc9b..df20918aa2 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -768,6 +768,7 @@ EvidenceSignals: MULTIPLE_USER_AGENT: DAMP NON_HTTP_PORT_80_CONNECTION: DAMP MALICIOUS_IP_FROM_P2P_NETWORK: DAMP + SSH_SUCCESSFUL: DAMP From d83a8feffc90e6b2be336510a6d2d27267c6695b Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 21:28:54 +0000 Subject: [PATCH 0138/1100] feat: clarify responsible IP handling and update documentation in T Cell module --- docs/t_cell_module.md | 94 +++++++++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 8b6f1e080a..62890b44f8 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -22,20 +22,65 @@ modules: 3. It extracts structured antigen values from evidence and linked altflows. 4. It matches those values against accepted regexes already stored by `RegexGenerator`. -5. It stores `DAMP` observations as profile-level danger signals and folds +5. It stores `DAMP` observations as responsible-IP danger signals and folds them into co-stimulation and context pressure for later `PAMP` reevaluations. 6. It computes co-stimulation and context scores. 7. It either becomes tolerant, activates, requests blocking, or stores memory. -The target of any effector response is always `evidence.profile.ip`, matching -the existing Slips blocking path. +The target of any effector response is the IP that T Cell identifies as the +responsible source for the attack. This is not always the same as +`evidence.profile.ip`. + +## Profile, Source, and Target + +The evidence object carries three different notions that must not be mixed: + +- `evidence.profile.ip`: the Slips profile bucket that the evidence belongs to. + It is the host related to the evidence in the current time window. +- `evidence.attacker`: the attacking or responsible entity. In IDMEF export, + this becomes `Source`. +- `evidence.victim`: the attacked entity. In IDMEF export, this becomes + `Target`. + +The `direction` field on `attacker` or `victim` says whether that entity was +seen on the network flow source side (`SRC`) or destination side (`DST`). That +flow-side direction is separate from the attack role: + +- `attacker` maps to IDMEF `Source` +- `victim` maps to IDMEF `Target` +- `direction=SRC/DST` maps to the network flow side + +T Cell uses a separate derived value called the responsible IP: + +1. If `evidence.attacker` is an IP, T Cell uses `evidence.attacker.value`. +2. Otherwise, if either evidence entity is an IP on the network `SRC` side, + T Cell uses that IP. +3. Otherwise, it falls back to `evidence.profile.ip`. + +This responsible IP is the IP that T Cell: + +- keys the cell on +- aggregates co-stimulation and context observations on +- sends to `new_blocking` when effector action is approved + +The original `evidence.profile.ip` is still logged, because it tells you which +host/time-window context produced the evidence. + +Example: + +- `profile.ip = 147.32.80.37` +- `Source.IP = 138.68.100.107` +- `Target.IP = 147.32.80.37` + +In that case, T Cell keeps `147.32.80.37` as the related profile context, but +the responsible IP for analysis and blocking is `138.68.100.107`. ## State Machine One T Cell is tracked per: -- target profile IP +- responsible IP - regex type - normalized antigen value @@ -56,7 +101,7 @@ The runtime flow is: and stops for that evidence after storing the observation. 4. Stored `DAMP` observations do not create or match cells, but they are kept as danger inputs and are included in the next co-stimulation or context - evaluation for the same `profile.ip`. + evaluation for the same responsible IP. 5. If no structured antigen can be extracted, the module logs `no_antigen_extracted` and stops for that evidence. 6. For each antigen candidate, the module loads or creates the cell in @@ -70,7 +115,7 @@ The runtime flow is: 10. If a regex matches, the cell goes `0 -> 1` and stores the chosen regex metadata. 11. The module computes co-stimulation from the current `PAMP`, related - `PAMP`s, and stored `DAMP` danger pressure for the same profile. + `PAMP`s, and stored `DAMP` danger pressure for the same responsible IP. 12. If co-stimulation crosses the configured threshold, the cell goes `1 -> 3`. 13. If co-stimulation stays below threshold, the cell can wait in `1 - antigen-recognized` for at most one configured Slips time window. @@ -85,12 +130,15 @@ The runtime flow is: 18. If state `3` cannot decide effector or memory within one configured Slips time window, the cell goes `3 -> 0 - mature`. -State `4` publishes the existing `new_blocking` payload when blocking support -is present. If blocking or ARP poisoning modules are not running, the module -can simulate the effector decision and log the exact payload instead. +State `4` publishes the existing `new_blocking` payload for the responsible IP +when blocking support is present. If blocking or ARP poisoning modules are not +running, the module can simulate the effector decision and log the exact +payload instead. State `5` stores the matched regex and the full context snapshot in the T Cell -SQLite DB. It does not emit a new Slips evidence. +SQLite DB when the cell first enters memory. It does not emit a new Slips +evidence. Later matching evidence keeps the cell in `5 - memory`, but it does +not create repeated `memory_stored` actions for the same cell. ## Antigen Extraction @@ -153,11 +201,11 @@ Where: - `profile_danger_score = min(1, combined_danger_raw / danger_saturation)` - `combined_danger_raw = pamp_danger_raw + damp_danger_weight * damp_danger_raw` - `pamp_danger_raw = sum(threat_level_value * confidence)` over recent `PAMP` - observations for the same `profile.ip` + observations for the same responsible IP - `damp_danger_raw = sum(threat_level_value * confidence)` over recent `DAMP` - observations for the same `profile.ip` + observations for the same responsible IP -Related PAMPs are recent `PAMP` observations for the same `profile.ip` that +Related PAMPs are recent `PAMP` observations for the same responsible IP that share either: - the same antigen value, or @@ -179,7 +227,8 @@ Interpretation: - `PAMP`s still provide antigen identity and the related-antigen correlation. - `DAMP`s do not match regexes and do not create cells. - `DAMP`s increase the danger term, so the same recognized antigen is treated - as riskier when the profile is also showing damage or anomaly signals. + as riskier when the responsible IP is also showing damage or anomaly + signals. Wait limit: @@ -253,7 +302,7 @@ shape used by the existing Slips blocking path: ```json { - "ip": "", + "ip": "", "block": true, "tw": 1, "interface": null @@ -262,7 +311,7 @@ shape used by the existing Slips blocking path: Notes: -- `ip` is always `evidence.profile.ip` +- `ip` is the derived responsible IP, not necessarily `evidence.profile.ip` - `tw` is `evidence.timewindow.number` - `interface` uses the same `utils.get_interface_of_ip()` lookup as the rest of Slips @@ -292,8 +341,9 @@ Default DB location: Tables: - `observations`: one processed evidence row with confidence, threat level, - extracted antigens, matched regexes, and the raw evidence JSON -- `cells`: current state for each `profile_ip + regex_type + antigen_value` + extracted antigens, matched regexes, the tracked responsible IP, and the raw + evidence JSON +- `cells`: current state for each `responsible_ip + regex_type + antigen_value` - `transitions`: auditable state transitions with reasons and score snapshots - `memories`: stored state-5 regex/context snapshots @@ -314,7 +364,9 @@ decision or transition, with: - action - resulting state - evidence type and ID -- profile IP +- related profile IP +- responsible IP +- target IP when the evidence victim is an IP - cell key - matched regex hash and value when relevant - main scores @@ -413,5 +465,5 @@ See [Evidence Signals](evidence_signals.md) for: T Cell antigen recognition and state creation start only from `PAMP`. `DAMP` observations are still stored in the T Cell observation table and are used as weighted danger signals in co-stimulation and context calculations for -the same `profile.ip`, but they do not create cells or perform regex matching -by themselves. +the same responsible IP, but they do not create cells or perform regex +matching by themselves. From c689f75c3b1b7d04472b72934d3108c99d4bd970 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 21:29:03 +0000 Subject: [PATCH 0139/1100] feat: update README to clarify responsible IP terminology and behavior in T Cell module --- modules/t_cell/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index 13a383d320..44188f3fef 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -6,7 +6,7 @@ It does not modify detector modules. Instead, it subscribes to the shared `evidence_added` channel, reads the centrally assigned `evidence_signal`, and creates one T Cell per: -- `profile.ip` +- responsible IP - regex type - normalized antigen value @@ -15,13 +15,17 @@ Main behavior: - only `PAMP` evidence starts antigen recognition and cell creation - antigens are extracted from evidence fields plus linked DNS/HTTP/SSL altflows - accepted regexes come from the existing RegexGenerator SQLite store +- `evidence.profile.ip` is the related host context, while containment and + T-cell ownership use the evidence's responsible IP - stored `DAMP` observations raise the danger pressure used by - co-stimulation and context for the same `profile.ip` + co-stimulation and context for the same responsible IP - co-stimulation and context scores decide whether the cell becomes tolerant, activates, requests containment, or stores memory - state `1 - antigen-recognized` and state `3 - activated` can each wait for at most one configured Slips time window before timing out to `2 - anergic` or `0 - mature` +- once a cell reaches `5 - memory`, later matching evidence keeps it in memory + without emitting repeated `memory_stored` actions - containment reuses the existing `new_blocking` payload shape - all T Cell state is stored in its own SQLite DB and log file From 9082d7c42566ad1757501767567f7d3c24975c1d Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 21:29:08 +0000 Subject: [PATCH 0140/1100] feat: enhance responsible IP handling in T Cell module --- modules/t_cell/t_cell.py | 110 +++++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 11 deletions(-) diff --git a/modules/t_cell/t_cell.py b/modules/t_cell/t_cell.py index d975eccdde..016072bc86 100644 --- a/modules/t_cell/t_cell.py +++ b/modules/t_cell/t_cell.py @@ -210,6 +210,7 @@ def _process_evidence_message(self, message: dict): return now = time.time() + responsible_ip = self._get_responsible_ip(evidence) antigens = self._extract_antigen_candidates(evidence) if antigens: self._log_event( @@ -230,7 +231,7 @@ def _process_evidence_message(self, message: dict): "evidence_id": evidence.id, "evidence_type": str(evidence.evidence_type), "evidence_signal": str(evidence.evidence_signal), - "profile_ip": evidence.profile.ip, + "profile_ip": responsible_ip, "timewindow_number": evidence.timewindow.number, "timestamp": evidence.timestamp, "observed_at": now, @@ -274,7 +275,11 @@ def _process_evidence_message(self, message: dict): for candidate in antigens: match = self._process_candidate( - evidence, observation_id, candidate, now + evidence, + observation_id, + candidate, + now, + responsible_ip, ) if match: matched_regexes.append(match.as_dict()) @@ -288,9 +293,10 @@ def _process_candidate( observation_id: int, candidate: AntigenCandidate, now: float, + responsible_ip: str, ) -> RegexMatch | None: cell = self._get_or_create_cell( - evidence.profile.ip, candidate.regex_type, candidate.value, now + responsible_ip, candidate.regex_type, candidate.value, now ) if ( @@ -388,8 +394,32 @@ def _process_candidate( **match_updates, ) + if cell["state"] == STATE_MEMORY: + self._update_cell( + cell, + now, + context={ + "reason": "memory_retained", + "observation_id": observation_id, + "matched_regex_hash": match.regex_hash, + }, + ) + self._log_event( + action="memory_retained", + state=STATE_MEMORY, + evidence=evidence, + cell=cell, + match=match, + details=( + "memory already exists for this cell; keeping the memory " + "state without storing a new memory event" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + return match + co_stimulation = self._compute_co_stimulation( - evidence.profile.ip, + responsible_ip, observation_id, candidate, match, @@ -473,7 +503,7 @@ def _process_candidate( return match context = self._compute_context_signals( - evidence.profile.ip, + responsible_ip, observation_id, candidate, match, @@ -499,7 +529,14 @@ def _process_candidate( match=match, scores=context, ) - self._apply_effector(cell, evidence, match, context, now) + self._apply_effector( + cell, + evidence, + match, + context, + now, + responsible_ip, + ) return match if context["memory"]: @@ -858,6 +895,7 @@ def _apply_effector( match: RegexMatch, context: dict, now: float, + responsible_ip: str, ): cooldown_until = cell.get("effector_cooldown_until") or 0 if now < cooldown_until: @@ -876,12 +914,13 @@ def _apply_effector( ) return - target_ip = evidence.profile.ip blocking_data = { - "ip": target_ip, + "ip": responsible_ip, "block": True, "tw": evidence.timewindow.number, - "interface": utils.get_interface_of_ip(target_ip, self.db, self.args), + "interface": utils.get_interface_of_ip( + responsible_ip, self.db, self.args + ), } next_cooldown = now + self.effector_cooldown_seconds self._update_cell( @@ -1061,11 +1100,12 @@ def _extract_from_entity(self, entity, candidates: dict): if not entity: return - if entity.ioc_type == IoCType.DOMAIN: + ioc_type = self._enum_name(getattr(entity, "ioc_type", None)) + if ioc_type == "DOMAIN": self._add_candidate( candidates, "dns_domain", self._normalize_domain(entity.value) ) - elif entity.ioc_type == IoCType.URL: + elif ioc_type == "URL": parsed = urlparse(str(entity.value or "").strip()) self._add_candidate( candidates, "dns_domain", self._normalize_domain(parsed.hostname) @@ -1080,6 +1120,48 @@ def _extract_from_entity(self, entity, candidates: dict): candidates, "tls_sni", self._normalize_domain(getattr(entity, "SNI", "")) ) + @staticmethod + def _enum_name(value) -> str: + if hasattr(value, "name"): + return str(value.name).upper() + raw_value = str(value or "").strip() + if "." in raw_value: + raw_value = raw_value.rsplit(".", 1)[-1] + return raw_value.upper() + + def _get_entity_ip(self, entity) -> str: + if not entity: + return "" + if self._enum_name(getattr(entity, "ioc_type", None)) != "IP": + return "" + value = str(getattr(entity, "value", "") or "").strip() + if not utils.is_valid_ip(value): + return "" + return value + + def _get_responsible_ip(self, evidence) -> str: + attacker_ip = self._get_entity_ip(getattr(evidence, "attacker", None)) + if attacker_ip: + return attacker_ip + + for entity in ( + getattr(evidence, "attacker", None), + getattr(evidence, "victim", None), + ): + if self._enum_name(getattr(entity, "direction", None)) != "SRC": + continue + entity_ip = self._get_entity_ip(entity) + if entity_ip: + return entity_ip + + return str(getattr(getattr(evidence, "profile", None), "ip", "") or "") + + def _get_target_ip(self, evidence) -> str: + victim_ip = self._get_entity_ip(getattr(evidence, "victim", None)) + if victim_ip: + return victim_ip + return "" + def _count_related_observations( self, observations: list[dict], @@ -1260,6 +1342,12 @@ def _log_event( parts.append(f"evidence={evidence.evidence_type.name}") parts.append(f"eid={evidence.id}") parts.append(f"profile={evidence.profile.ip}") + responsible_ip = self._get_responsible_ip(evidence) + if responsible_ip: + parts.append(f"responsible={responsible_ip}") + target_ip = self._get_target_ip(evidence) + if target_ip: + parts.append(f"target={target_ip}") if cell: parts.append(f"cell={cell['cell_key']}") if match: From a6186c75585494596d9398556f85415fe0d1c53e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 21:29:16 +0000 Subject: [PATCH 0141/1100] feat: add tests for responsible IP handling and memory event management in T Cell module --- tests/unit/modules/t_cell/test_t_cell.py | 163 +++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/tests/unit/modules/t_cell/test_t_cell.py b/tests/unit/modules/t_cell/test_t_cell.py index 1b73898a26..eebb30d09f 100644 --- a/tests/unit/modules/t_cell/test_t_cell.py +++ b/tests/unit/modules/t_cell/test_t_cell.py @@ -391,6 +391,7 @@ def test_t_cell_effector_publishes_blocking_and_respects_cooldown(tmp_path): match, {"effector_score": 0.95}, fixed_now + 1, + profile_ip, ) assert t_cell.db.publish.call_count == 1 @@ -491,6 +492,89 @@ def test_t_cell_moves_to_memory_and_stores_context(tmp_path): assert any(memory["cell_key"] == cell["cell_key"] for memory in memories) +def test_t_cell_does_not_repeat_memory_events_for_same_cell(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=3) + fixed_now = 12_500.0 + profile_ip = "10.0.0.66" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence_1 = _build_evidence( + "memory-repeat-1", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.MEDIUM, + confidence=0.5, + ) + evidence_2 = _build_evidence( + "memory-repeat-2", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.MEDIUM, + confidence=0.5, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "memory-repeat-regex" + ) + t_cell.db.get_pid_of.return_value = None + + for index in range(5): + _insert_observation( + storage=storage, + evidence_id=f"hist-old-repeat-{index}", + profile_ip=profile_ip, + antigens=[antigen.as_dict()], + observed_at=fixed_now - 2400 - index, + confidence=1.0, + threat_level_value=0.8, + ) + for index in range(3): + _insert_observation( + storage=storage, + evidence_id=f"hist-new-repeat-{index}", + profile_ip=profile_ip, + antigens=[antigen.as_dict()], + observed_at=fixed_now - 300 - index, + confidence=0.5, + threat_level_value=0.5, + threat_level="medium", + ) + storage.upsert_memory( + { + "cell_key": "seeded-memory-cell", + "profile_ip": "10.0.0.1", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "regex_hash": "memory-repeat-regex", + "regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "context": {"seeded": True}, + "created_at": fixed_now - 100, + "updated_at": fixed_now - 100, + } + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_1)) + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_2)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_MEMORY + assert sum( + 1 for transition in transitions if transition["reason"] == "context_memory" + ) == 1 + + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + log_contents = log_file.read() + + assert log_contents.count("action=memory_stored") == 1 + assert "action=memory_retained" in log_contents + + def test_t_cell_context_times_out_after_one_tw(tmp_path): t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) t_cell.state_wait_timeout_seconds = 100.0 @@ -708,3 +792,82 @@ def test_t_cell_log_file_contains_color_codes(tmp_path): assert "\033[" in log_contents assert "4 - effector" in log_contents + + +def test_t_cell_uses_responsible_attacker_ip_for_cell_and_blocking(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=3) + fixed_now = 16_000.0 + responsible_ip = "138.68.100.107" + related_profile_ip = "147.32.80.37" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence = _build_evidence( + "responsible-ip-1", + profile_ip=related_profile_ip, + attacker=Attacker( + direction=Direction.SRC, + ioc_type=IoCType.IP, + value=responsible_ip, + ), + victim=Victim( + direction=Direction.DST, + ioc_type=IoCType.IP, + value=related_profile_ip, + ), + uids=["dns-1"], + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "responsible-ip-regex" + ) + t_cell.db.get_pid_of.side_effect = ( + lambda name: 123 if name == "Blocking" else None + ) + _seed_recent_related_observations( + storage, responsible_ip, antigen, fixed_now, count=4 + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence)) + + cell = storage.get_all_cells()[0] + assert cell["cell_key"].startswith(f"{responsible_ip}|") + assert cell["profile_ip"] == responsible_ip + + channel, payload = t_cell.db.publish.call_args.args + assert channel == "new_blocking" + assert json.loads(payload) == { + "ip": responsible_ip, + "block": True, + "tw": 1, + "interface": None, + } + + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + log_contents = log_file.read() + + assert f"profile={related_profile_ip}" in log_contents + assert f"responsible={responsible_ip}" in log_contents + assert f"target={related_profile_ip}" in log_contents + + +def test_t_cell_falls_back_to_src_side_ip_when_attacker_is_not_ip(tmp_path): + t_cell, _ = _prepare_t_cell(tmp_path) + evidence = _build_evidence( + "src-fallback-1", + profile_ip="203.0.113.50", + attacker=Attacker( + direction=Direction.DST, + ioc_type=IoCType.DOMAIN, + value="bad.example.com", + ), + victim=Victim( + direction=Direction.SRC, + ioc_type=IoCType.IP, + value="10.0.0.50", + ), + ) + + assert t_cell._get_responsible_ip(evidence) == "10.0.0.50" From 4b26f1e1fa0afefc25f135526e1ce234db82f997 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 21:31:50 +0000 Subject: [PATCH 0142/1100] feat: add method to convert starttime to profile score in ProfileHandler --- slips_files/core/database/redis_db/profile_handler.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/slips_files/core/database/redis_db/profile_handler.py b/slips_files/core/database/redis_db/profile_handler.py index 8a0004fc12..68b347a35f 100644 --- a/slips_files/core/database/redis_db/profile_handler.py +++ b/slips_files/core/database/redis_db/profile_handler.py @@ -19,6 +19,7 @@ import validators from redis.client import Pipeline +from slips_files.common.slips_utils import utils from slips_files.core.structures.flow_attributes import Role @@ -1049,7 +1050,7 @@ def add_profile(self, profileid, starttime, confidence=0.05) -> bool: self.zadd_but_keep_n_entries( self.constants.PROFILES, - {str(profileid): float(starttime)}, + {str(profileid): self._get_profile_start_score(starttime)}, 2000, ) @@ -1082,6 +1083,13 @@ def add_profile(self, profileid, starttime, confidence=0.05) -> bool: self.print(inst, 0, 1) return False + @staticmethod + def _get_profile_start_score(starttime) -> float: + try: + return float(utils.convert_ts_format(starttime, "unixtimestamp")) + except Exception: + return float(starttime) + def set_module_label_for_profile(self, profileid, module, label): """ Set a module label for a profile. From 80054c57eae8ab531f57fadb3377ae48265adb87 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 21:31:56 +0000 Subject: [PATCH 0143/1100] feat: add test for handling alerts format timestamp in profile addition --- .../database/redis_db/test_profile_handler.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/slips_files/core/database/redis_db/test_profile_handler.py b/tests/unit/slips_files/core/database/redis_db/test_profile_handler.py index 16c39c741b..8ed386db9c 100644 --- a/tests/unit/slips_files/core/database/redis_db/test_profile_handler.py +++ b/tests/unit/slips_files/core/database/redis_db/test_profile_handler.py @@ -4,6 +4,7 @@ from unittest.mock import patch, MagicMock, call, Mock import json from tests.module_factory import ModuleFactory +from slips_files.common.slips_utils import utils from slips_files.core.structures.flow_attributes import Role from slips_files.core.flows.zeek import HTTP, DNS from unittest.mock import ANY @@ -2015,6 +2016,32 @@ def test_add_profile_existing_profile(): handler.update_threat_level.assert_not_called() +def test_add_profile_accepts_alerts_format_timestamp(): + handler = ModuleFactory().create_profile_handler_obj() + + handler.set_new_ip = MagicMock() + handler.publish = MagicMock() + + profileid = f"profile{handler.separator}1" + starttime = utils.convert_ts_format(1678886400.0, utils.alerts_format) + + handler.r.zscore.return_value = None + + pipe = MagicMock() + handler.r.pipeline = MagicMock(return_value=pipe) + pipe.__enter__ = MagicMock(return_value=pipe) + pipe.__exit__ = MagicMock(return_value=False) + + result = handler.add_profile(profileid, starttime) + + assert result is True + handler.zadd_but_keep_n_entries.assert_called_once_with( + handler.constants.PROFILES, + {profileid: 1678886400.0}, + 2000, + ) + + def test_mark_profile_as_dhcp_profile_not_exist(): handler = ModuleFactory().create_profile_handler_obj() From fe5c75e25a74a39070b8bc74e2b210011174d989 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Fri, 20 Mar 2026 21:34:01 +0000 Subject: [PATCH 0144/1100] feat: update evidence signals and default classifications in documentation --- docs/evidence_signals.md | 41 ++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md index 6123f48f8c..02720b9b4f 100644 --- a/docs/evidence_signals.md +++ b/docs/evidence_signals.md @@ -6,7 +6,7 @@ The `T Cell` module consumes this same central field and only activates its state machine for antigen recognition from `PAMP` evidence. `DAMP` evidence is still stored by the module as an observation and contributes to the danger pressure used in T-cell co-stimulation and context calculations for the same -profile IP, but it does not create cells or perform regex matching. See +responsible IP, but it does not create cells or perform regex matching. See [T Cell Module](t_cell_module.md) for the responder details. The supported values are: @@ -26,6 +26,15 @@ EvidenceSignals: overrides: ANOMALOUS_FLOW: DAMP MALICIOUS_FLOW: DAMP + ARP_SCAN: DAMP + UNSOLICITED_ARP: DAMP + CONNECTION_TO_MULTIPLE_PORTS: DAMP + CONNECTION_TO_PRIVATE_IP: DAMP + CONNECTION_WITHOUT_DNS: DAMP + DNS_ARPA_SCAN: DAMP + DNS_WITHOUT_CONNECTION: DAMP + HIGH_ENTROPY_DNS_ANSWER: DAMP + LONG_CONNECTION: DAMP ``` Rules: @@ -33,7 +42,11 @@ Rules: - `default_signal` is applied to every evidence type that is not listed in `overrides`. - `overrides` keys are evidence type names from `EvidenceType`. - Invalid values fall back to `PAMP`. -- The default shipped mapping marks `ANOMALOUS_FLOW` and `MALICIOUS_FLOW` as `DAMP`. +- The default shipped mapping marks the following evidence types as `DAMP`: + `ANOMALOUS_FLOW`, `MALICIOUS_FLOW`, `ARP_SCAN`, `UNSOLICITED_ARP`, + `CONNECTION_TO_MULTIPLE_PORTS`, `CONNECTION_TO_PRIVATE_IP`, + `CONNECTION_WITHOUT_DNS`, `DNS_ARPA_SCAN`, `DNS_WITHOUT_CONNECTION`, + `HIGH_ENTROPY_DNS_ANSWER`, and `LONG_CONNECTION`. ## Propagation @@ -51,28 +64,28 @@ The table below lists the evidence types currently emitted by Slips modules and | Module | Evidence type | Default signal | | --- | --- | --- | | `anomaly_detection_https` | `ANOMALOUS_FLOW` | `DAMP` | -| `arp` | `ARP_SCAN` | `PAMP` | +| `arp` | `ARP_SCAN` | `DAMP` | | `arp` | `ARP_OUTSIDE_LOCALNET` | `PAMP` | -| `arp` | `UNSOLICITED_ARP` | `PAMP` | +| `arp` | `UNSOLICITED_ARP` | `DAMP` | | `arp` | `MITM_ARP_ATTACK` | `PAMP` | | `flowalerts` | `BAD_SMTP_LOGIN` | `PAMP` | | `flowalerts` | `CN_URL_MISMATCH` | `PAMP` | -| `flowalerts` | `CONNECTION_TO_MULTIPLE_PORTS` | `PAMP` | -| `flowalerts` | `CONNECTION_TO_PRIVATE_IP` | `PAMP` | -| `flowalerts` | `CONNECTION_WITHOUT_DNS` | `PAMP` | +| `flowalerts` | `CONNECTION_TO_MULTIPLE_PORTS` | `DAMP` | +| `flowalerts` | `CONNECTION_TO_PRIVATE_IP` | `DAMP` | +| `flowalerts` | `CONNECTION_WITHOUT_DNS` | `DAMP` | | `flowalerts` | `DATA_UPLOAD` | `PAMP` | | `flowalerts` | `DEVICE_CHANGING_IP` | `PAMP` | | `flowalerts` | `DGA_NXDOMAINS` | `PAMP` | | `flowalerts` | `DIFFERENT_LOCALNET` | `PAMP` | -| `flowalerts` | `DNS_ARPA_SCAN` | `PAMP` | -| `flowalerts` | `DNS_WITHOUT_CONNECTION` | `PAMP` | +| `flowalerts` | `DNS_ARPA_SCAN` | `DAMP` | +| `flowalerts` | `DNS_WITHOUT_CONNECTION` | `DAMP` | | `flowalerts` | `GRE_SCAN` | `PAMP` | | `flowalerts` | `GRE_TUNNEL` | `PAMP` | -| `flowalerts` | `HIGH_ENTROPY_DNS_ANSWER` | `PAMP` | +| `flowalerts` | `HIGH_ENTROPY_DNS_ANSWER` | `DAMP` | | `flowalerts` | `HORIZONTAL_PORT_SCAN` | `PAMP` | | `flowalerts` | `INCOMPATIBLE_CN` | `PAMP` | | `flowalerts` | `INVALID_DNS_RESOLUTION` | `PAMP` | -| `flowalerts` | `LONG_CONNECTION` | `PAMP` | +| `flowalerts` | `LONG_CONNECTION` | `DAMP` | | `flowalerts` | `MALICIOUS_JA3` | `PAMP` | | `flowalerts` | `MALICIOUS_JA3S` | `PAMP` | | `flowalerts` | `MALICIOUS_SSL_CERT` | `PAMP` | @@ -121,4 +134,8 @@ The table below lists the evidence types currently emitted by Slips modules and `ANOMALOUS_FLOW` is emitted by `anomaly_detection_https`, while `MALICIOUS_FLOW` is emitted by `flowmldetection`. Both are marked as `DAMP` by default in the -central signal configuration. +central signal configuration. The additional shipped DAMP overrides in +`config/slips.yaml` are `ARP_SCAN`, `UNSOLICITED_ARP`, +`CONNECTION_TO_MULTIPLE_PORTS`, `CONNECTION_TO_PRIVATE_IP`, +`CONNECTION_WITHOUT_DNS`, `DNS_ARPA_SCAN`, `DNS_WITHOUT_CONNECTION`, +`HIGH_ENTROPY_DNS_ANSWER`, and `LONG_CONNECTION`. From 0b586e94efe6d59116252e8fd9fea0baa8128c7b Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:08 +0000 Subject: [PATCH 0145/1100] feat: enhance T Cell module logging and decision tracing configuration --- config/slips.yaml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/config/slips.yaml b/config/slips.yaml index df20918aa2..2a2c6443a7 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -345,7 +345,22 @@ t_cell: # 1 = transitions and terminal actions only # 2 = add decision summaries such as waiting for co-stimulation/context # 3 = add per-evidence debug details such as extracted antigens - log_verbosity: 1 + log_verbosity: 3 + + # Optional evaluation trace for auditing why thresholds passed or failed. + # off = disabled + # transitions = write detailed traces only when a state transition happens + # all = also trace waiting evaluations + decision_trace_mode: off + + # Separate trace file used only when decision_trace_mode is not off. + # This path is always resolved inside the selected output directory for the + # current Slips run. Absolute or outside paths are not honored. + decision_trace_file: t_cell_trace.jsonl + + # Maximum number of contributing evidence rows stored per contributor list + # inside the trace file. + decision_trace_max_evidence: 10 # Directory that stores the isolated T Cell SQLite database. # Absolute paths are used as-is. Relative paths are resolved inside the @@ -362,7 +377,8 @@ t_cell: # regex for its antigen. anergy_ttl_seconds: 21600 - # Time window used to count related PAMP observations for the same profile. + # Time window used to count related PAMP observations for the same + # responsible IP. related_lookback_seconds: 3600 # Saturation point for related PAMP scoring. A value of 5 means that 5 or @@ -375,7 +391,7 @@ t_cell: # DAMP observations do not create or match T Cells, but they do increase # the danger pressure used by co-stimulation and context for the same - # profile IP: + # responsible IP: # combined_raw_danger = pamp_raw_danger + damp_danger_weight * damp_raw_danger damp_danger_weight: 1.5 @@ -750,12 +766,10 @@ EvidenceSignals: overrides: ANOMALOUS_FLOW: DAMP MALICIOUS_FLOW: DAMP - ARP_SCAN: DAMP UNSOLICITED_ARP: DAMP CONNECTION_TO_MULTIPLE_PORTS: DAMP CONNECTION_TO_PRIVATE_IP: DAMP CONNECTION_WITHOUT_DNS: DAMP - DNS_ARPA_SCAN: DAMP DNS_WITHOUT_CONNECTION: DAMP HIGH_ENTROPY_DNS_ANSWER: DAMP LONG_CONNECTION: DAMP @@ -767,7 +781,6 @@ EvidenceSignals: HTTP_TRAFFIC: DAMP MULTIPLE_USER_AGENT: DAMP NON_HTTP_PORT_80_CONNECTION: DAMP - MALICIOUS_IP_FROM_P2P_NETWORK: DAMP SSH_SUCCESSFUL: DAMP From 7cbba8dc3b7a10483bbb6438538c25f2b9ced433 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:14 +0000 Subject: [PATCH 0146/1100] feat: update evidence signal classifications and add new overrides in documentation --- docs/evidence_signals.md | 53 ++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md index 02720b9b4f..544898b8a0 100644 --- a/docs/evidence_signals.md +++ b/docs/evidence_signals.md @@ -26,15 +26,22 @@ EvidenceSignals: overrides: ANOMALOUS_FLOW: DAMP MALICIOUS_FLOW: DAMP - ARP_SCAN: DAMP UNSOLICITED_ARP: DAMP CONNECTION_TO_MULTIPLE_PORTS: DAMP CONNECTION_TO_PRIVATE_IP: DAMP CONNECTION_WITHOUT_DNS: DAMP - DNS_ARPA_SCAN: DAMP DNS_WITHOUT_CONNECTION: DAMP HIGH_ENTROPY_DNS_ANSWER: DAMP LONG_CONNECTION: DAMP + MULTIPLE_RECONNECTION_ATTEMPTS: DAMP + MULTIPLE_SSH_VERSIONS: DAMP + NON_SSL_PORT_443_CONNECTION: DAMP + UNKNOWN_PORT: DAMP + YOUNG_DOMAIN: DAMP + HTTP_TRAFFIC: DAMP + MULTIPLE_USER_AGENT: DAMP + NON_HTTP_PORT_80_CONNECTION: DAMP + SSH_SUCCESSFUL: DAMP ``` Rules: @@ -43,10 +50,14 @@ Rules: - `overrides` keys are evidence type names from `EvidenceType`. - Invalid values fall back to `PAMP`. - The default shipped mapping marks the following evidence types as `DAMP`: - `ANOMALOUS_FLOW`, `MALICIOUS_FLOW`, `ARP_SCAN`, `UNSOLICITED_ARP`, + `ANOMALOUS_FLOW`, `MALICIOUS_FLOW`, `UNSOLICITED_ARP`, `CONNECTION_TO_MULTIPLE_PORTS`, `CONNECTION_TO_PRIVATE_IP`, - `CONNECTION_WITHOUT_DNS`, `DNS_ARPA_SCAN`, `DNS_WITHOUT_CONNECTION`, - `HIGH_ENTROPY_DNS_ANSWER`, and `LONG_CONNECTION`. + `CONNECTION_WITHOUT_DNS`, `DNS_WITHOUT_CONNECTION`, + `HIGH_ENTROPY_DNS_ANSWER`, `LONG_CONNECTION`, + `MULTIPLE_RECONNECTION_ATTEMPTS`, `MULTIPLE_SSH_VERSIONS`, + `NON_SSL_PORT_443_CONNECTION`, `UNKNOWN_PORT`, `YOUNG_DOMAIN`, + `HTTP_TRAFFIC`, `MULTIPLE_USER_AGENT`, `NON_HTTP_PORT_80_CONNECTION`, + and `SSH_SUCCESSFUL`. ## Propagation @@ -64,7 +75,7 @@ The table below lists the evidence types currently emitted by Slips modules and | Module | Evidence type | Default signal | | --- | --- | --- | | `anomaly_detection_https` | `ANOMALOUS_FLOW` | `DAMP` | -| `arp` | `ARP_SCAN` | `DAMP` | +| `arp` | `ARP_SCAN` | `PAMP` | | `arp` | `ARP_OUTSIDE_LOCALNET` | `PAMP` | | `arp` | `UNSOLICITED_ARP` | `DAMP` | | `arp` | `MITM_ARP_ATTACK` | `PAMP` | @@ -77,7 +88,7 @@ The table below lists the evidence types currently emitted by Slips modules and | `flowalerts` | `DEVICE_CHANGING_IP` | `PAMP` | | `flowalerts` | `DGA_NXDOMAINS` | `PAMP` | | `flowalerts` | `DIFFERENT_LOCALNET` | `PAMP` | -| `flowalerts` | `DNS_ARPA_SCAN` | `DAMP` | +| `flowalerts` | `DNS_ARPA_SCAN` | `PAMP` | | `flowalerts` | `DNS_WITHOUT_CONNECTION` | `DAMP` | | `flowalerts` | `GRE_SCAN` | `PAMP` | | `flowalerts` | `GRE_TUNNEL` | `PAMP` | @@ -89,25 +100,25 @@ The table below lists the evidence types currently emitted by Slips modules and | `flowalerts` | `MALICIOUS_JA3` | `PAMP` | | `flowalerts` | `MALICIOUS_JA3S` | `PAMP` | | `flowalerts` | `MALICIOUS_SSL_CERT` | `PAMP` | -| `flowalerts` | `MULTIPLE_RECONNECTION_ATTEMPTS` | `PAMP` | -| `flowalerts` | `MULTIPLE_SSH_VERSIONS` | `PAMP` | -| `flowalerts` | `NON_SSL_PORT_443_CONNECTION` | `PAMP` | +| `flowalerts` | `MULTIPLE_RECONNECTION_ATTEMPTS` | `DAMP` | +| `flowalerts` | `MULTIPLE_SSH_VERSIONS` | `DAMP` | +| `flowalerts` | `NON_SSL_PORT_443_CONNECTION` | `DAMP` | | `flowalerts` | `PASSWORD_GUESSING` | `PAMP` | | `flowalerts` | `PASTEBIN_DOWNLOAD` | `PAMP` | | `flowalerts` | `PORT_0_CONNECTION` | `PAMP` | | `flowalerts` | `SELF_SIGNED_CERTIFICATE` | `PAMP` | | `flowalerts` | `SMTP_LOGIN_BRUTEFORCE` | `PAMP` | -| `flowalerts` | `SSH_SUCCESSFUL` | `PAMP` | -| `flowalerts` | `UNKNOWN_PORT` | `PAMP` | +| `flowalerts` | `SSH_SUCCESSFUL` | `DAMP` | +| `flowalerts` | `UNKNOWN_PORT` | `DAMP` | | `flowalerts` | `VERTICAL_PORT_SCAN` | `PAMP` | -| `flowalerts` | `YOUNG_DOMAIN` | `PAMP` | +| `flowalerts` | `YOUNG_DOMAIN` | `DAMP` | | `flowmldetection` | `MALICIOUS_FLOW` | `DAMP` | | `http_analyzer` | `EMPTY_CONNECTIONS` | `PAMP` | | `http_analyzer` | `EXECUTABLE_MIME_TYPE` | `PAMP` | -| `http_analyzer` | `HTTP_TRAFFIC` | `PAMP` | +| `http_analyzer` | `HTTP_TRAFFIC` | `DAMP` | | `http_analyzer` | `INCOMPATIBLE_USER_AGENT` | `PAMP` | -| `http_analyzer` | `MULTIPLE_USER_AGENT` | `PAMP` | -| `http_analyzer` | `NON_HTTP_PORT_80_CONNECTION` | `PAMP` | +| `http_analyzer` | `MULTIPLE_USER_AGENT` | `DAMP` | +| `http_analyzer` | `NON_HTTP_PORT_80_CONNECTION` | `DAMP` | | `http_analyzer` | `PASTEBIN_DOWNLOAD` | `PAMP` | | `http_analyzer` | `SUSPICIOUS_USER_AGENT` | `PAMP` | | `http_analyzer` | `WEIRD_HTTP_METHOD` | `PAMP` | @@ -135,7 +146,11 @@ The table below lists the evidence types currently emitted by Slips modules and `ANOMALOUS_FLOW` is emitted by `anomaly_detection_https`, while `MALICIOUS_FLOW` is emitted by `flowmldetection`. Both are marked as `DAMP` by default in the central signal configuration. The additional shipped DAMP overrides in -`config/slips.yaml` are `ARP_SCAN`, `UNSOLICITED_ARP`, +`config/slips.yaml` are `UNSOLICITED_ARP`, `CONNECTION_TO_MULTIPLE_PORTS`, `CONNECTION_TO_PRIVATE_IP`, -`CONNECTION_WITHOUT_DNS`, `DNS_ARPA_SCAN`, `DNS_WITHOUT_CONNECTION`, -`HIGH_ENTROPY_DNS_ANSWER`, and `LONG_CONNECTION`. +`CONNECTION_WITHOUT_DNS`, `DNS_WITHOUT_CONNECTION`, +`HIGH_ENTROPY_DNS_ANSWER`, `LONG_CONNECTION`, +`MULTIPLE_RECONNECTION_ATTEMPTS`, `MULTIPLE_SSH_VERSIONS`, +`NON_SSL_PORT_443_CONNECTION`, `UNKNOWN_PORT`, `YOUNG_DOMAIN`, +`HTTP_TRAFFIC`, `MULTIPLE_USER_AGENT`, `NON_HTTP_PORT_80_CONNECTION`, +and `SSH_SUCCESSFUL`. From 62364d9598c35386587770d54149c79ca2c05559 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:21 +0000 Subject: [PATCH 0147/1100] feat: add decision trace feature for T Cell module with configurable options --- docs/t_cell_module.md | 46 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 62890b44f8..af8e2a444b 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -379,6 +379,45 @@ decision or transition, with: wait time, wait limit, and the split between `PAMP` and `DAMP` danger - `3`: also log per-evidence debug details such as extracted antigens +### Decision Trace + +For verification runs, the module also supports a separate audit trace file: + +```text +/t_cell_trace.jsonl +``` + +This trace is disabled by default. When enabled, each JSON line explains one +co-stimulation or context evaluation and includes: + +- the action being decided, for example `co_stimulation_threshold_met`, + `context_memory`, or `waiting_for_context` +- the related profile IP, responsible IP, and target IP +- the candidate antigen and matched regex +- the exact score, threshold, and weighted formula terms +- the evidence IDs that contributed to the related-PAMP count +- the evidence IDs that contributed to `PAMP` and `DAMP` danger totals +- omitted-contributor counts when the trace limit is reached + +The trace path is always resolved under the output directory selected for the +current Slips run. If the config contains an absolute path or a path that tries +to escape the output directory, the module collapses it back under the selected +run output directory before writing the file. + +Recommended usage: + +- keep `decision_trace_mode: off` during normal runs +- use `decision_trace_mode: transitions` when you only want threshold-passing + and state-change explanations +- use `decision_trace_mode: all` only for focused evaluation runs where you + also want waiting decisions + +Performance note: + +- with `decision_trace_mode: off`, there is effectively no extra trace cost +- trace mode performs extra observation lookups and extra file writes, so it + should be treated as a verification feature, not the normal default path + Color mapping: - `0 - mature` -> cyan @@ -398,6 +437,9 @@ t_cell: create_log_file: true log_colors: true log_verbosity: 1 + decision_trace_mode: off + decision_trace_file: t_cell_trace.jsonl + decision_trace_max_evidence: 10 store_dir: output/t_cell persistent_store_dir: "" observation_retention_seconds: 604800 @@ -429,6 +471,10 @@ Reference: - `log_colors`: keep ANSI colors in the module log - `log_verbosity`: `1` logs transitions/actions only, `2` adds decision summaries, `3` adds per-evidence debug details +- `decision_trace_mode`: `off`, `transitions`, or `all` +- `decision_trace_file`: JSONL audit file for threshold explanations, always + created under the selected run output directory +- `decision_trace_max_evidence`: contributor cap per trace list - `store_dir`: run-local directory for the SQLite DB - `persistent_store_dir`: optional stable absolute directory for the DB - `observation_retention_seconds`: retention for observation rows From fb49b3f7c1a6c1d446ff173d1ccebc6a8d0212cc Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:26 +0000 Subject: [PATCH 0148/1100] feat: update README to include optional decision tracing and trace file details --- modules/t_cell/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index 44188f3fef..3473befe02 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -19,6 +19,8 @@ Main behavior: T-cell ownership use the evidence's responsible IP - stored `DAMP` observations raise the danger pressure used by co-stimulation and context for the same responsible IP +- optional decision tracing writes a separate JSONL audit file showing which + evidence IDs contributed to threshold calculations - co-stimulation and context scores decide whether the cell becomes tolerant, activates, requests containment, or stores memory - state `1 - antigen-recognized` and state `3 - activated` can each wait for @@ -32,6 +34,9 @@ Main behavior: Artifacts: - module log: `output/t_cell.log` +- optional trace file: `/t_cell_trace.jsonl` + The configured trace path is always forced under the selected run output + directory. - module DB: `/t_cell/t_cell.sqlite` See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, From 4a247a24e9220508810e6c760cce39106fddc58c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:31 +0000 Subject: [PATCH 0149/1100] feat: implement decision tracing for T Cell module with configurable options --- modules/t_cell/t_cell.py | 550 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 544 insertions(+), 6 deletions(-) diff --git a/modules/t_cell/t_cell.py b/modules/t_cell/t_cell.py index 016072bc86..ded0c8fc56 100644 --- a/modules/t_cell/t_cell.py +++ b/modules/t_cell/t_cell.py @@ -54,6 +54,9 @@ LOG_VERBOSITY_SUMMARY = 1 LOG_VERBOSITY_DECISIONS = 2 LOG_VERBOSITY_DEBUG = 3 +TRACE_MODE_OFF = 0 +TRACE_MODE_TRANSITIONS = 1 +TRACE_MODE_ALL = 2 @dataclass(frozen=True) @@ -102,6 +105,11 @@ def init(self): self.log_colors = True self.log_verbosity = LOG_VERBOSITY_SUMMARY self.log_file_path = os.path.join(self.output_dir, "t_cell.log") + self.decision_trace_mode = TRACE_MODE_OFF + self.decision_trace_max_evidence = 10 + self.trace_file_path = os.path.join( + self.output_dir, "t_cell_trace.jsonl" + ) self.storage = None self.state_wait_timeout_seconds = 3600.0 self.observation_retention_seconds = 604800 @@ -129,6 +137,13 @@ def read_configuration(self): self.create_log_file = conf.t_cell_create_log_file() self.log_colors = conf.t_cell_log_colors() self.log_verbosity = conf.t_cell_log_verbosity() + self.decision_trace_mode = conf.t_cell_decision_trace_mode() + self.decision_trace_max_evidence = ( + conf.t_cell_decision_trace_max_evidence() + ) + self.trace_file_path = self._resolve_trace_file_path( + conf.t_cell_decision_trace_file() + ) try: self.state_wait_timeout_seconds = float( conf.get_tw_width_in_seconds() @@ -173,6 +188,7 @@ def pre_main(self): self.storage = self.db.get_t_cell_storage() self._init_log_file() + self._init_trace_file() self._log_detail("T Cell module ready.") return False @@ -253,7 +269,6 @@ def _process_evidence_message(self, message: dict): action="ignored_non_pamp", state=None, evidence=evidence, - details=f"signal={evidence.evidence_signal}", verbosity=LOG_VERBOSITY_DECISIONS, ) self._prune_observations(now) @@ -435,6 +450,19 @@ def _process_candidate( if cell["state"] < STATE_ACTIVATED: wait_elapsed = self._get_state_wait_elapsed(cell, now) if co_stimulation["value"] >= self.co_stimulation_threshold: + self._maybe_trace_co_stimulation( + action="co_stimulation_threshold_met", + evidence=evidence, + cell=cell, + candidate=candidate, + match=match, + co_stimulation=co_stimulation, + responsible_ip=responsible_ip, + observation_id=observation_id, + now=now, + from_state=cell["state"], + to_state=STATE_ACTIVATED, + ) cell = self._transition_cell( cell=cell, to_state=STATE_ACTIVATED, @@ -449,6 +477,24 @@ def _process_candidate( cell["state"] == STATE_ANTIGEN_RECOGNIZED and self._state_wait_expired(cell, now) ): + self._maybe_trace_co_stimulation( + action="co_stimulation_timeout", + evidence=evidence, + cell=cell, + candidate=candidate, + match=match, + co_stimulation={ + **co_stimulation, + "elapsed": wait_elapsed, + "wait_limit": self.state_wait_timeout_seconds, + "anergic_until": now + self.anergy_ttl_seconds, + }, + responsible_ip=responsible_ip, + observation_id=observation_id, + now=now, + from_state=cell["state"], + to_state=STATE_ANERGIC, + ) cell = self._transition_cell( cell=cell, to_state=STATE_ANERGIC, @@ -469,6 +515,23 @@ def _process_candidate( ) return match else: + self._maybe_trace_co_stimulation( + action="waiting_for_co_stimulation", + evidence=evidence, + cell=cell, + candidate=candidate, + match=match, + co_stimulation={ + **co_stimulation, + "elapsed": wait_elapsed, + "wait_limit": self.state_wait_timeout_seconds, + }, + responsible_ip=responsible_ip, + observation_id=observation_id, + now=now, + from_state=cell["state"], + to_state=cell["state"], + ) self._log_event( action="waiting_for_co_stimulation", state=cell["state"], @@ -518,6 +581,19 @@ def _process_candidate( ) if context["effector"]: + self._maybe_trace_context( + action="context_effector", + evidence=evidence, + cell=cell, + candidate=candidate, + match=match, + context=context, + responsible_ip=responsible_ip, + observation_id=observation_id, + now=now, + from_state=cell["state"], + to_state=STATE_EFFECTOR, + ) if cell["state"] != STATE_EFFECTOR: cell = self._transition_cell( cell=cell, @@ -540,6 +616,19 @@ def _process_candidate( return match if context["memory"]: + self._maybe_trace_context( + action="context_memory", + evidence=evidence, + cell=cell, + candidate=candidate, + match=match, + context=context, + responsible_ip=responsible_ip, + observation_id=observation_id, + now=now, + from_state=cell["state"], + to_state=STATE_MEMORY, + ) if cell["state"] != STATE_MEMORY: cell = self._transition_cell( cell=cell, @@ -568,6 +657,23 @@ def _process_candidate( cell["state"] == STATE_ACTIVATED and self._state_wait_expired(cell, now) ): + self._maybe_trace_context( + action="context_timeout", + evidence=evidence, + cell=cell, + candidate=candidate, + match=match, + context={ + **context, + "elapsed": wait_elapsed, + "wait_limit": self.state_wait_timeout_seconds, + }, + responsible_ip=responsible_ip, + observation_id=observation_id, + now=now, + from_state=cell["state"], + to_state=STATE_MATURE, + ) self._transition_cell( cell=cell, to_state=STATE_MATURE, @@ -584,6 +690,23 @@ def _process_candidate( ) return match + self._maybe_trace_context( + action="waiting_for_context", + evidence=evidence, + cell=cell, + candidate=candidate, + match=match, + context={ + **context, + "elapsed": wait_elapsed, + "wait_limit": self.state_wait_timeout_seconds, + }, + responsible_ip=responsible_ip, + observation_id=observation_id, + now=now, + from_state=cell["state"], + to_state=cell["state"], + ) self._log_event( action="waiting_for_context", state=cell["state"], @@ -872,6 +995,372 @@ def _compute_context_signals( "memory": memory, } + def _maybe_trace_co_stimulation( + self, + action: str, + evidence, + cell: dict, + candidate: AntigenCandidate, + match: RegexMatch, + co_stimulation: dict, + responsible_ip: str, + observation_id: int, + now: float, + from_state: int, + to_state: int, + ): + if not self._should_write_decision_trace(action): + return + + since_ts = now - self.related_lookback_seconds + pamp_observations = self.storage.get_recent_observations( + responsible_ip, + since_ts, + evidence_signal="PAMP", + ) + damp_observations = self.storage.get_recent_observations( + responsible_ip, + since_ts, + evidence_signal="DAMP", + ) + current_observation = self.storage.get_observation(observation_id) or {} + related_trace = self._build_related_trace( + pamp_observations, + candidate, + match.regex_hash, + exclude_observation_id=observation_id, + ) + pamp_danger_trace = self._build_danger_trace(pamp_observations) + damp_danger_trace = self._build_danger_trace(damp_observations) + + entry = { + "ts": utils.convert_ts_format(now, utils.alerts_format), + "stage": "co_stimulation", + "action": action, + "from_state": STATE_INFO[from_state]["label"], + "to_state": STATE_INFO[to_state]["label"], + "cell_key": cell["cell_key"], + "profile_ip": evidence.profile.ip, + "responsible_ip": responsible_ip, + "target_ip": self._get_target_ip(evidence), + "candidate": candidate.as_dict(), + "match": match.as_dict(), + "current_evidence": self._summarize_current_observation( + evidence, current_observation + ), + "formula": { + "value": co_stimulation["value"], + "threshold": co_stimulation["threshold"], + "weights": self.co_stimulation_weights, + "components": { + "confidence": { + "value": co_stimulation["confidence"], + "weighted": ( + self.co_stimulation_weights["confidence"] + * co_stimulation["confidence"] + ), + "evidence_id": evidence.id, + }, + "related_pamps": { + "count": co_stimulation["related_pamp_count"], + "saturation": self.related_pamps_saturation, + "score": co_stimulation["related_pamp_score"], + "weighted": ( + self.co_stimulation_weights["related_pamps"] + * co_stimulation["related_pamp_score"] + ), + "contributors": related_trace["contributors"], + "omitted_count": related_trace["omitted_count"], + }, + "danger": { + "score": co_stimulation["profile_danger_score"], + "weighted": ( + self.co_stimulation_weights["danger"] + * co_stimulation["profile_danger_score"] + ), + "danger_saturation": self.danger_saturation, + "damp_weight": self.damp_danger_weight, + "pamp_score": co_stimulation["pamp_danger_score"], + "damp_score": co_stimulation["damp_danger_score"], + "pamp_total_raw": pamp_danger_trace["total_raw"], + "damp_total_raw": damp_danger_trace["total_raw"], + "pamp_contributors": pamp_danger_trace["contributors"], + "pamp_omitted_count": pamp_danger_trace["omitted_count"], + "damp_contributors": damp_danger_trace["contributors"], + "damp_omitted_count": damp_danger_trace["omitted_count"], + }, + }, + }, + } + self._write_decision_trace(entry) + + def _maybe_trace_context( + self, + action: str, + evidence, + cell: dict, + candidate: AntigenCandidate, + match: RegexMatch, + context: dict, + responsible_ip: str, + observation_id: int, + now: float, + from_state: int, + to_state: int, + ): + if not self._should_write_decision_trace(action): + return + + recent_start = now - self.context_recent_window_seconds + previous_start = now - (2 * self.context_recent_window_seconds) + recent_pamp_observations = self.storage.get_recent_observations( + responsible_ip, + recent_start, + evidence_signal="PAMP", + ) + recent_damp_observations = self.storage.get_recent_observations( + responsible_ip, + recent_start, + evidence_signal="DAMP", + ) + previous_pamp_observations = self.storage.get_recent_observations( + responsible_ip, + previous_start, + until_ts=recent_start, + evidence_signal="PAMP", + ) + previous_damp_observations = self.storage.get_recent_observations( + responsible_ip, + previous_start, + until_ts=recent_start, + evidence_signal="DAMP", + ) + current_observation = self.storage.get_observation(observation_id) or {} + related_trace = self._build_related_trace( + recent_pamp_observations, + candidate, + match.regex_hash, + exclude_observation_id=observation_id, + ) + has_memory = self.storage.has_memory_for_regex(match.regex_hash) + has_recent_activity = self.storage.has_recent_regex_activity( + responsible_ip, + match.regex_hash, + now - self.novelty_window_seconds, + exclude_observation_id=observation_id, + ) + + entry = { + "ts": utils.convert_ts_format(now, utils.alerts_format), + "stage": "context", + "action": action, + "from_state": STATE_INFO[from_state]["label"], + "to_state": STATE_INFO[to_state]["label"], + "cell_key": cell["cell_key"], + "profile_ip": evidence.profile.ip, + "responsible_ip": responsible_ip, + "target_ip": self._get_target_ip(evidence), + "candidate": candidate.as_dict(), + "match": match.as_dict(), + "current_evidence": self._summarize_current_observation( + evidence, current_observation + ), + "formula": { + "effector_score": context["effector_score"], + "effector_threshold": context["effector_threshold"], + "memory_score": context["memory_score"], + "memory_threshold": context["memory_threshold"], + "decision": { + "effector": context["effector"], + "memory": context["memory"], + }, + "components": { + "novelty": { + "score": context["novelty_score"], + "has_memory_for_regex": has_memory, + "has_recent_regex_activity": has_recent_activity, + "novelty_window_seconds": self.novelty_window_seconds, + }, + "recent_related": { + "count": context["recent_related_count"], + "saturation": self.related_pamps_saturation, + "score": context["recent_related_score"], + "contributors": related_trace["contributors"], + "omitted_count": related_trace["omitted_count"], + }, + "recent_pressure": self._build_pressure_trace( + recent_pamp_observations, + recent_damp_observations, + context["recent_pressure"], + context["recent_pamp_pressure"], + context["recent_damp_pressure"], + ), + "previous_pressure": self._build_pressure_trace( + previous_pamp_observations, + previous_damp_observations, + context["previous_pressure"], + context["previous_pamp_pressure"], + context["previous_damp_pressure"], + ), + "trend_ratio": context["trend_ratio"], + "decrease_score": context["decrease_score"], + "familiarity_score": context["familiarity_score"], + "stability_score": context["stability_score"], + }, + }, + } + self._write_decision_trace(entry) + + def _build_pressure_trace( + self, + pamp_observations: list[dict], + damp_observations: list[dict], + combined_score: float, + pamp_score: float, + damp_score: float, + ) -> dict: + pamp_trace = self._build_danger_trace(pamp_observations) + damp_trace = self._build_danger_trace(damp_observations) + return { + "combined_score": combined_score, + "pamp_score": pamp_score, + "damp_score": damp_score, + "danger_saturation": self.danger_saturation, + "damp_weight": self.damp_danger_weight, + "pamp_total_raw": pamp_trace["total_raw"], + "damp_total_raw": damp_trace["total_raw"], + "pamp_contributors": pamp_trace["contributors"], + "pamp_omitted_count": pamp_trace["omitted_count"], + "damp_contributors": damp_trace["contributors"], + "damp_omitted_count": damp_trace["omitted_count"], + } + + def _summarize_current_observation( + self, evidence, observation: dict | None + ) -> dict: + observation = observation or {} + return { + "observation_id": observation.get("id"), + "evidence_id": evidence.id, + "evidence_type": evidence.evidence_type.name, + "signal": str(evidence.evidence_signal), + "confidence": observation.get("confidence", evidence.confidence), + "threat_level": observation.get( + "threat_level", str(evidence.threat_level) + ), + "threat_level_value": observation.get( + "threat_level_value", float(evidence.threat_level.value) + ), + "danger_contribution": self._observation_danger_contribution( + observation + ) + if observation + else float(evidence.confidence) * float(evidence.threat_level.value), + } + + def _build_related_trace( + self, + observations: list[dict], + candidate: AntigenCandidate, + regex_hash: str, + exclude_observation_id: int, + ) -> dict: + contributors = [] + for observation in observations: + if observation["id"] == exclude_observation_id: + continue + relations = self._get_observation_relations( + observation, candidate, regex_hash + ) + if not relations: + continue + contributors.append( + self._summarize_observation( + observation, + relations=relations, + ) + ) + + return self._limit_trace_items(contributors) + + def _build_danger_trace(self, observations: list[dict]) -> dict: + contributors = [ + self._summarize_observation(observation) + for observation in observations + ] + limited = self._limit_trace_items(contributors) + limited["total_raw"] = sum( + item["danger_contribution"] for item in contributors + ) + return limited + + def _summarize_observation( + self, + observation: dict, + relations: list[str] | None = None, + ) -> dict: + summary = { + "observation_id": observation.get("id"), + "evidence_id": observation.get("evidence_id"), + "evidence_type": observation.get("evidence_type"), + "signal": observation.get("evidence_signal"), + "observed_at": observation.get("observed_at"), + "confidence": float(observation.get("confidence", 0.0)), + "threat_level": observation.get("threat_level"), + "threat_level_value": float( + observation.get("threat_level_value", 0.0) + ), + "danger_contribution": self._observation_danger_contribution( + observation + ), + } + if relations: + summary["relations"] = relations + return summary + + @staticmethod + def _observation_danger_contribution(observation: dict) -> float: + return float(observation.get("threat_level_value", 0.0)) * float( + observation.get("confidence", 0.0) + ) + + def _limit_trace_items(self, items: list[dict]) -> dict: + ordered_items = sorted( + items, + key=lambda item: ( + float(item.get("danger_contribution", 0.0)), + float(item.get("observed_at", 0.0) or 0.0), + ), + reverse=True, + ) + return { + "contributors": ordered_items[: self.decision_trace_max_evidence], + "omitted_count": max( + 0, + len(ordered_items) - self.decision_trace_max_evidence, + ), + } + + def _should_write_decision_trace(self, action: str) -> bool: + if self.decision_trace_mode == TRACE_MODE_OFF: + return False + if self.decision_trace_mode >= TRACE_MODE_ALL: + return True + return action not in { + "waiting_for_co_stimulation", + "waiting_for_context", + } + + def _write_decision_trace(self, entry: dict): + if self.decision_trace_mode == TRACE_MODE_OFF: + return + trace_dir = os.path.dirname(self.trace_file_path) + if trace_dir: + os.makedirs(trace_dir, exist_ok=True) + with open(self.trace_file_path, "a", encoding="utf-8") as trace_file: + trace_file.write(json.dumps(entry, sort_keys=True)) + trace_file.write("\n") + def _is_novel_regex( self, profile_ip: str, @@ -1177,20 +1666,35 @@ def _count_related_observations( count += 1 return count - @staticmethod def _is_related_observation( - observation: dict, candidate: AntigenCandidate, regex_hash: str + self, + observation: dict, + candidate: AntigenCandidate, + regex_hash: str, ) -> bool: + return bool( + self._get_observation_relations(observation, candidate, regex_hash) + ) + + @staticmethod + def _get_observation_relations( + observation: dict, + candidate: AntigenCandidate, + regex_hash: str, + ) -> list[str]: + relations = [] for antigen in observation.get("antigens", []): if ( antigen.get("regex_type") == candidate.regex_type and antigen.get("value") == candidate.value ): - return True + relations.append("same_antigen") + break for match in observation.get("matched_regexes", []): if regex_hash and match.get("regex_hash") == regex_hash: - return True - return False + relations.append("same_regex_hash") + break + return relations @staticmethod def _sum_danger(observations: list[dict]) -> float: @@ -1313,6 +1817,39 @@ def _init_log_file(self): with open(self.log_file_path, "w", encoding="utf-8") as log_file: log_file.write("") + def _init_trace_file(self): + if self.decision_trace_mode == TRACE_MODE_OFF: + return + trace_dir = os.path.dirname(self.trace_file_path) + if trace_dir: + os.makedirs(trace_dir, exist_ok=True) + with open(self.trace_file_path, "w", encoding="utf-8") as trace_file: + trace_file.write("") + + def _resolve_trace_file_path(self, raw_path: str) -> str: + normalized = str(raw_path or "").strip() + if not normalized: + normalized = "t_cell_trace.jsonl" + + normalized = normalized.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + if os.path.isabs(normalized): + normalized = os.path.basename(normalized) + if normalized.startswith("output/"): + normalized = normalized[len("output/") :] + + safe_parts = [] + for part in normalized.split("/"): + if not part or part in (".", ".."): + continue + if part.endswith(":"): + continue + safe_parts.append(part) + if not safe_parts: + safe_parts = ["t_cell_trace.jsonl"] + return os.path.join(self.output_dir, *safe_parts) + def _colorize_state(self, state: int) -> str: label = STATE_INFO[state]["label"] if not self.log_colors: @@ -1341,6 +1878,7 @@ def _log_event( if evidence: parts.append(f"evidence={evidence.evidence_type.name}") parts.append(f"eid={evidence.id}") + parts.append(f"signal={evidence.evidence_signal}") parts.append(f"profile={evidence.profile.ip}") responsible_ip = self._get_responsible_ip(evidence) if responsible_ip: From ded137cd544b12fbc2acfb54fbcb87f7fb489cd2 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:35 +0000 Subject: [PATCH 0150/1100] feat: add T Cell decision trace configuration options --- slips_files/common/parsers/config_parser.py | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/slips_files/common/parsers/config_parser.py b/slips_files/common/parsers/config_parser.py index c02cb6b6f1..db57d13456 100644 --- a/slips_files/common/parsers/config_parser.py +++ b/slips_files/common/parsers/config_parser.py @@ -982,6 +982,51 @@ def t_cell_log_verbosity(self) -> int: value = 1 return max(1, min(3, int(value))) + def t_cell_decision_trace_mode(self) -> int: + value = self.read_configuration("t_cell", "decision_trace_mode", "off") + if isinstance(value, bool): + return 1 if value else 0 + if isinstance(value, (int, float)): + return max(0, min(2, int(value))) + + normalized = str(value).strip().lower() + named_levels = { + "off": 0, + "disabled": 0, + "none": 0, + "transitions": 1, + "transition": 1, + "state_changes": 1, + "changes": 1, + "all": 2, + "full": 2, + "debug": 2, + } + if normalized in named_levels: + return named_levels[normalized] + try: + return max(0, min(2, int(normalized))) + except (TypeError, ValueError): + return 0 + + def t_cell_decision_trace_file(self) -> str: + value = self.read_configuration( + "t_cell", "decision_trace_file", "t_cell_trace.jsonl" + ) + if not isinstance(value, str) or not value.strip(): + return "t_cell_trace.jsonl" + return value.strip() + + def t_cell_decision_trace_max_evidence(self) -> int: + value = self.read_configuration( + "t_cell", "decision_trace_max_evidence", 10 + ) + try: + value = int(value) + except (TypeError, ValueError): + value = 10 + return max(1, value) + def t_cell_store_dir(self) -> str: value = self.read_configuration("t_cell", "store_dir", "output/t_cell") if not isinstance(value, str) or not value.strip(): From 6fe1af126c12d4e4aa18042c3e5b097da663fc68 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:40 +0000 Subject: [PATCH 0151/1100] feat: add T Cell decision trace configuration options in tests --- tests/module_factory.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/module_factory.py b/tests/module_factory.py index 0c3336c74a..31b232c662 100644 --- a/tests/module_factory.py +++ b/tests/module_factory.py @@ -258,6 +258,11 @@ def create_t_cell_obj(self, mock_db): conf.t_cell_create_log_file = Mock(return_value=True) conf.t_cell_log_colors = Mock(return_value=True) conf.t_cell_log_verbosity = Mock(return_value=1) + conf.t_cell_decision_trace_mode = Mock(return_value=0) + conf.t_cell_decision_trace_file = Mock( + return_value="t_cell_trace.jsonl" + ) + conf.t_cell_decision_trace_max_evidence = Mock(return_value=10) conf.get_tw_width_in_seconds = Mock(return_value=3600.0) conf.t_cell_store_dir = Mock(return_value="dummy_output_dir/t_cell") conf.t_cell_persistent_store_dir = Mock(return_value="") From b3713d89e2b0074ea5ef7d1651534c6bdbbdb13f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:44 +0000 Subject: [PATCH 0152/1100] feat: enhance T Cell module with decision tracing and logging improvements --- tests/unit/modules/t_cell/test_t_cell.py | 138 ++++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/tests/unit/modules/t_cell/test_t_cell.py b/tests/unit/modules/t_cell/test_t_cell.py index eebb30d09f..9f39d534c7 100644 --- a/tests/unit/modules/t_cell/test_t_cell.py +++ b/tests/unit/modules/t_cell/test_t_cell.py @@ -41,15 +41,24 @@ def _build_storage(tmp_path): return TCellStorage(Mock(), conf, str(tmp_path), 12345) -def _prepare_t_cell(tmp_path, log_verbosity: int = 3): +def _prepare_t_cell( + tmp_path, + log_verbosity: int = 3, + trace_mode: int = 0, + trace_max_evidence: int = 10, +): t_cell = ModuleFactory().create_t_cell_obj() t_cell.output_dir = str(tmp_path) t_cell.log_file_path = str(tmp_path / "t_cell.log") + t_cell.trace_file_path = str(tmp_path / "t_cell_trace.jsonl") storage = _build_storage(tmp_path) t_cell.db.get_t_cell_storage.return_value = storage with patch("modules.t_cell.t_cell.utils.drop_root_privs_permanently"): assert t_cell.pre_main() is False t_cell.log_verbosity = log_verbosity + t_cell.decision_trace_mode = trace_mode + t_cell.decision_trace_max_evidence = trace_max_evidence + t_cell._init_trace_file() return t_cell, storage @@ -92,6 +101,15 @@ def _message_for(evidence: Evidence) -> dict: return {"data": json.dumps(utils.to_dict(evidence))} +def _read_trace_entries(trace_path): + with open(trace_path, encoding="utf-8") as trace_file: + return [ + json.loads(line) + for line in trace_file + if line.strip() + ] + + def _insert_observation( storage, evidence_id: str, @@ -223,7 +241,33 @@ def test_t_cell_ignores_damp_evidence(tmp_path): assert storage.get_all_cells() == [] t_cell.db.publish.assert_not_called() with open(t_cell.log_file_path, encoding="utf-8") as log_file: - assert "ignored_non_pamp" in log_file.read() + log_contents = log_file.read() + assert "ignored_non_pamp" in log_contents + assert "signal=DAMP" in log_contents + + +def test_t_cell_antigen_log_includes_evidence_signal(tmp_path): + t_cell, _ = _prepare_t_cell(tmp_path) + evidence = _build_evidence( + "damp-antigen-1", + signal=EvidenceSignal.DAMP, + attacker=Attacker( + direction=Direction.SRC, + ioc_type=IoCType.URL, + value="https://download.bad.example.com/payload/run.exe", + ), + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=2050.0): + t_cell._process_evidence_message(_message_for(evidence)) + + with open(t_cell.log_file_path, encoding="utf-8") as log_file: + log_lines = log_file.read().splitlines() + + antigen_line = next( + line for line in log_lines if "action=antigens_extracted" in line + ) + assert "signal=DAMP" in antigen_line def test_t_cell_skips_pamp_without_antigens(tmp_path): @@ -871,3 +915,93 @@ def test_t_cell_falls_back_to_src_side_ip_when_attacker_is_not_ip(tmp_path): ) assert t_cell._get_responsible_ip(evidence) == "10.0.0.50" + + +def test_t_cell_transition_trace_lists_contributing_evidence(tmp_path): + t_cell, storage = _prepare_t_cell( + tmp_path, trace_mode=1, trace_max_evidence=10 + ) + fixed_now = 17_000.0 + profile_ip = "10.0.0.67" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence = _build_evidence("trace-transition-1", profile_ip=profile_ip, uids=["dns-1"]) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "trace-transition-regex" + ) + t_cell.db.get_pid_of.side_effect = ( + lambda name: 123 if name == "Blocking" else None + ) + _seed_recent_related_observations( + storage, profile_ip, antigen, fixed_now, count=4 + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence)) + + entries = _read_trace_entries(t_cell.trace_file_path) + actions = [entry["action"] for entry in entries] + + assert "co_stimulation_threshold_met" in actions + assert "context_effector" in actions + + co_stim_entry = next( + entry + for entry in entries + if entry["action"] == "co_stimulation_threshold_met" + ) + assert co_stim_entry["formula"]["components"]["related_pamps"]["count"] == 4 + related_ids = { + item["evidence_id"] + for item in co_stim_entry["formula"]["components"]["related_pamps"][ + "contributors" + ] + } + assert "hist-recent-0" in related_ids + pamp_danger_ids = { + item["evidence_id"] + for item in co_stim_entry["formula"]["components"]["danger"][ + "pamp_contributors" + ] + } + assert evidence.id in pamp_danger_ids + + +def test_t_cell_all_trace_includes_waiting_evaluations(tmp_path): + t_cell, _ = _prepare_t_cell(tmp_path, trace_mode=2) + evidence = _build_evidence("trace-wait-1", uids=["dns-1"]) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "trace-wait-regex" + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=18_000.0): + t_cell._process_evidence_message(_message_for(evidence)) + + entries = _read_trace_entries(t_cell.trace_file_path) + assert [entry["action"] for entry in entries] == [ + "waiting_for_co_stimulation" + ] + assert ( + entries[0]["formula"]["components"]["related_pamps"]["count"] == 0 + ) + + +def test_t_cell_trace_file_is_forced_inside_output_dir(tmp_path): + t_cell = ModuleFactory().create_t_cell_obj() + t_cell.output_dir = str(tmp_path / "selected-output") + t_cell.conf.t_cell_decision_trace_file.return_value = ( + "/tmp/escape/outside_trace.jsonl" + ) + + t_cell.read_configuration() + + assert t_cell.trace_file_path == str( + tmp_path / "selected-output" / "outside_trace.jsonl" + ) From f69915aaeeb7c5696c09f40a5f313378286dbd28 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 09:46:51 +0000 Subject: [PATCH 0153/1100] feat: add T Cell decision trace configuration assertions in tests --- tests/unit/slips_files/common/test_config_parser.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/slips_files/common/test_config_parser.py b/tests/unit/slips_files/common/test_config_parser.py index be28044f9c..ecd5c651a6 100644 --- a/tests/unit/slips_files/common/test_config_parser.py +++ b/tests/unit/slips_files/common/test_config_parser.py @@ -40,6 +40,9 @@ def test_t_cell_config_defaults(): assert parser.t_cell_create_log_file() is True assert parser.t_cell_log_colors() is True assert parser.t_cell_log_verbosity() == 1 + assert parser.t_cell_decision_trace_mode() == 0 + assert parser.t_cell_decision_trace_file() == "t_cell_trace.jsonl" + assert parser.t_cell_decision_trace_max_evidence() == 10 assert parser.t_cell_store_dir() == "output/t_cell" assert parser.t_cell_persistent_store_dir() == "" assert parser.t_cell_observation_retention_seconds() == 604800 @@ -73,6 +76,9 @@ def test_t_cell_config_sanitization(): "create_log_file": "false", "log_colors": "false", "log_verbosity": "debug", + "decision_trace_mode": "all", + "decision_trace_file": " ", + "decision_trace_max_evidence": "bad", "store_dir": "", "persistent_store_dir": " /tmp/tcell ", "observation_retention_seconds": "bad", @@ -103,6 +109,9 @@ def test_t_cell_config_sanitization(): assert parser.t_cell_create_log_file() is False assert parser.t_cell_log_colors() is False assert parser.t_cell_log_verbosity() == 3 + assert parser.t_cell_decision_trace_mode() == 2 + assert parser.t_cell_decision_trace_file() == "t_cell_trace.jsonl" + assert parser.t_cell_decision_trace_max_evidence() == 10 assert parser.t_cell_store_dir() == "output/t_cell" assert parser.t_cell_persistent_store_dir() == "/tmp/tcell" assert parser.t_cell_observation_retention_seconds() == 604800 From 0f07061e0ac5e1619c7b60ab5e2ac0241e587f09 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:31 +0000 Subject: [PATCH 0154/1100] Implement feature X to enhance user experience and fix bug Y in module Z --- modules/t_cell/analyze_t_cell.py | 1841 ++++++++++++++++++++++++++++++ 1 file changed, 1841 insertions(+) create mode 100644 modules/t_cell/analyze_t_cell.py diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py new file mode 100644 index 0000000000..2acf246ccc --- /dev/null +++ b/modules/t_cell/analyze_t_cell.py @@ -0,0 +1,1841 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Generate a standalone local HTML report for a T Cell module run. + +Usage: + python3 modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ + +By default the script writes: + output//t_cell_report.html +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sqlite3 +from collections import Counter, defaultdict, deque +from datetime import datetime, timezone +from html import escape +from pathlib import Path +from typing import Any, Iterable + +try: + import yaml +except ImportError: # pragma: no cover - optional runtime dependency + yaml = None + + +ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") +STATE_LABELS = { + 0: "0 - mature", + 1: "1 - antigen-recognized", + 2: "2 - anergic", + 3: "3 - activated", + 4: "4 - effector", + 5: "5 - memory", +} +STATE_CLASS = { + 0: "state-mature", + 1: "state-recognized", + 2: "state-anergic", + 3: "state-activated", + 4: "state-effector", + 5: "state-memory", +} +STATE_COLORS = { + "state-mature": "#0f766e", + "state-recognized": "#d97706", + "state-anergic": "#2563eb", + "state-activated": "#a21caf", + "state-effector": "#b91c1c", + "state-memory": "#15803d", +} +SIGNAL_COLORS = {"PAMP": "#c2410c", "DAMP": "#0369a1"} +TRACE_STAGE_COLORS = {"co_stimulation": "#b45309", "context": "#7c3aed"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate an offline T Cell HTML report." + ) + parser.add_argument( + "--run-output-dir", + required=True, + help="Slips run output directory containing t_cell/t_cell.sqlite.", + ) + parser.add_argument( + "--out", + default="", + help="Output HTML path. Default: /t_cell_report.html", + ) + parser.add_argument( + "--max-observations", + type=int, + default=200, + help="Maximum recent observations to render in the report.", + ) + parser.add_argument( + "--max-log-lines", + type=int, + default=400, + help="Maximum recent module log lines to embed in the report.", + ) + parser.add_argument( + "--max-trace-rows", + type=int, + default=200, + help="Maximum recent trace rows to render in the report.", + ) + return parser.parse_args() + + +def load_json(raw_value: str, fallback): + try: + return json.loads(raw_value) + except (TypeError, ValueError): + return fallback + + +def parse_alerts_timestamp(raw_value: str | None) -> float | None: + if not raw_value: + return None + text = str(raw_value).strip() + if not text: + return None + + fmts = ( + "%Y/%m/%d %H:%M:%S.%f%z", + "%Y/%m/%d %H:%M:%S.%f", + "%Y/%m/%d %H:%M:%S%z", + "%Y/%m/%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f%z", + "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + ) + for fmt in fmts: + try: + value = datetime.strptime(text, fmt) + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.timestamp() + except ValueError: + continue + try: + return float(text) + except (TypeError, ValueError): + return None + + +def ts_to_iso(ts: float | None) -> str: + if ts is None: + return "n/a" + return ( + datetime.fromtimestamp(float(ts), tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def state_label(state: int | None) -> str: + return STATE_LABELS.get(state, f"unknown:{state}") + + +def state_class(state: int | None) -> str: + return STATE_CLASS.get(state, "state-unknown") + + +def shorten(value: Any, limit: int = 96) -> str: + text = str(value or "") + if len(text) <= limit: + return text + return text[: limit - 1] + "…" + + +def format_float(value: Any, digits: int = 3) -> str: + if value is None or value == "": + return "n/a" + try: + numeric = float(value) + except (TypeError, ValueError): + return str(value) + if math.isfinite(numeric) and abs(numeric - round(numeric)) < 1e-9: + return str(int(round(numeric))) + return f"{numeric:.{digits}f}" + + +def load_yaml_config(metadata_path: Path) -> dict: + if not metadata_path.exists() or yaml is None: + return {} + try: + return yaml.safe_load(metadata_path.read_text(encoding="utf-8")) or {} + except Exception: + return {} + + +def _row_to_observation(row: sqlite3.Row) -> dict: + return { + "id": row["id"], + "evidence_id": row["evidence_id"], + "evidence_type": row["evidence_type"], + "evidence_signal": row["evidence_signal"], + "responsible_ip": row["profile_ip"], + "timewindow_number": row["timewindow_number"], + "timestamp": row["timestamp"], + "observed_at": row["observed_at"], + "confidence": row["confidence"], + "threat_level": row["threat_level"], + "threat_level_value": row["threat_level_value"], + "interface": row["interface"], + "uids": load_json(row["uid_json"], []), + "antigen_count": row["antigen_count"], + "antigens": load_json(row["antigens_json"], []), + "matched_regexes": load_json(row["matched_regexes_json"], []), + "raw_evidence": load_json(row["raw_evidence_json"], {}), + } + + +def _row_to_cell(row: sqlite3.Row) -> dict: + return { + "cell_key": row["cell_key"], + "responsible_ip": row["profile_ip"], + "regex_type": row["regex_type"], + "antigen_value": row["antigen_value"], + "state": row["state"], + "state_name": row["state_name"], + "matched_regex_hash": row["matched_regex_hash"], + "matched_regex": row["matched_regex"], + "matched_value": row["matched_value"], + "anergic_until": row["anergic_until"], + "effector_cooldown_until": row["effector_cooldown_until"], + "last_observation_id": row["last_observation_id"], + "last_evidence_id": row["last_evidence_id"], + "last_transition_at": row["last_transition_at"], + "last_co_stimulation": row["last_co_stimulation"], + "last_effector_score": row["last_effector_score"], + "last_memory_score": row["last_memory_score"], + "context": load_json(row["context_json"], {}), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def _row_to_transition(row: sqlite3.Row) -> dict: + return { + "id": row["id"], + "cell_key": row["cell_key"], + "responsible_ip": row["profile_ip"], + "regex_type": row["regex_type"], + "antigen_value": row["antigen_value"], + "evidence_id": row["evidence_id"], + "observation_id": row["observation_id"], + "from_state": row["from_state"], + "to_state": row["to_state"], + "reason": row["reason"], + "matched_regex_hash": row["matched_regex_hash"], + "matched_regex": row["matched_regex"], + "matched_value": row["matched_value"], + "scores": load_json(row["scores_json"], {}), + "created_at": row["created_at"], + } + + +def _row_to_memory(row: sqlite3.Row) -> dict: + return { + "cell_key": row["cell_key"], + "responsible_ip": row["profile_ip"], + "regex_type": row["regex_type"], + "antigen_value": row["antigen_value"], + "regex_hash": row["regex_hash"], + "regex": row["regex"], + "matched_value": row["matched_value"], + "context": load_json(row["context_json"], {}), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def load_db_records(db_path: Path) -> dict: + if not db_path.exists(): + raise FileNotFoundError(f"T Cell DB not found: {db_path}") + + with sqlite3.connect(db_path) as conn: + conn.row_factory = sqlite3.Row + observations = [ + _row_to_observation(row) + for row in conn.execute( + "SELECT * FROM observations ORDER BY observed_at ASC, id ASC" + ) + ] + cells = [ + _row_to_cell(row) + for row in conn.execute( + "SELECT * FROM cells ORDER BY updated_at DESC, created_at DESC" + ) + ] + transitions = [ + _row_to_transition(row) + for row in conn.execute( + "SELECT * FROM transitions ORDER BY created_at ASC, id ASC" + ) + ] + memories = [ + _row_to_memory(row) + for row in conn.execute( + "SELECT * FROM memories ORDER BY updated_at DESC, created_at DESC" + ) + ] + return { + "observations": observations, + "cells": cells, + "transitions": transitions, + "memories": memories, + } + + +def parse_log_line(raw_line: str) -> dict | None: + line = ANSI_RE.sub("", raw_line.strip()) + if not line: + return None + + parts = [part.strip() for part in line.split(" | ")] + record = {"raw": line, "wall": parts[0], "ts": parse_alerts_timestamp(parts[0])} + extras = [] + for part in parts[1:]: + if "=" in part: + key, value = part.split("=", 1) + record[key] = value + else: + extras.append(part) + if extras: + record["details"] = " | ".join(extras) + return record + + +def load_log_entries(log_path: Path, max_lines: int) -> dict: + if not log_path.exists(): + return {"entries": [], "tail": []} + + entries = [] + tail = deque(maxlen=max(1, max_lines)) + with log_path.open("r", encoding="utf-8", errors="replace") as handle: + for raw_line in handle: + line = raw_line.rstrip("\n") + tail.append(ANSI_RE.sub("", line)) + parsed = parse_log_line(line) + if parsed: + entries.append(parsed) + return {"entries": entries, "tail": list(tail)} + + +def load_trace_entries(trace_path: Path) -> list[dict]: + if not trace_path.exists(): + return [] + entries = [] + with trace_path.open("r", encoding="utf-8", errors="replace") as handle: + for line in handle: + text = line.strip() + if not text: + continue + try: + entry = json.loads(text) + except json.JSONDecodeError: + continue + entry["_ts"] = parse_alerts_timestamp(entry.get("ts")) + entries.append(entry) + return entries + + +def entity_ip(entity: dict | None) -> str: + if not isinstance(entity, dict): + return "" + raw_type = str(entity.get("ioc_type") or "").upper() + if raw_type.endswith("IP") or raw_type == "IP": + return str(entity.get("value") or "") + return "" + + +def observation_related_profile(observation: dict) -> str: + raw = observation.get("raw_evidence") or {} + profile = raw.get("profile") or {} + if isinstance(profile, dict) and profile.get("ip"): + return str(profile.get("ip")) + return observation.get("responsible_ip") or "" + + +def observation_target_ip(observation: dict) -> str: + raw = observation.get("raw_evidence") or {} + return entity_ip(raw.get("victim")) + + +def observation_description(observation: dict) -> str: + raw = observation.get("raw_evidence") or {} + return str(raw.get("description") or "") + + +def summarize_antigens(antigens: list[dict], limit: int = 4) -> str: + if not antigens: + return "none" + return ", ".join( + f"{item.get('regex_type')}:{item.get('value')}" + for item in antigens[:limit] + ) + + +def summarize_matched_regexes(matches: list[dict], limit: int = 2) -> str: + if not matches: + return "none" + return ", ".join( + f"{item.get('regex_type')}:{item.get('value')}" + for item in matches[:limit] + ) + + +def categorize_observation(observation: dict, transition_map: dict[int, list[dict]]) -> str: + signal = observation.get("evidence_signal") + if signal != "PAMP": + if observation.get("antigen_count", 0) > 0: + return "DAMP with extracted antigens" + return "DAMP ignored for activation" + + if observation.get("antigen_count", 0) <= 0: + return "PAMP with no antigen" + + matches = observation.get("matched_regexes") or [] + if matches: + return "PAMP with regex match" + + transitions = transition_map.get(observation["id"], []) + if any(item.get("reason") == "no_regex_match" for item in transitions): + return "PAMP with no regex match" + return "PAMP with antigens but no stored match" + + +def top_counts(counter: Counter, limit: int = 12) -> list[dict]: + return [ + {"label": label, "count": count} + for label, count in counter.most_common(limit) + ] + + +def safe_div(num: float, den: float) -> float: + if not den: + return 0.0 + return num / den + + +def build_findings(report: dict) -> list[str]: + totals = report["totals"] + categories = report["observation_categories"] + current_states = report["cell_states"] + findings = [] + + if totals["observations"] == 0: + findings.append("No T-cell observations were stored for this run.") + return findings + + damp_count = totals["signals"].get("DAMP", 0) + pamp_count = totals["signals"].get("PAMP", 0) + if damp_count: + ratio = safe_div(damp_count, totals["observations"]) * 100.0 + findings.append( + f"Most evidence was DAMP: {damp_count}/{totals['observations']} " + f"observations ({ratio:.1f}%)." + ) + if pamp_count and categories.get("PAMP with no antigen", 0): + findings.append( + f"{categories['PAMP with no antigen']} PAMP observations stopped before " + "regex matching because no supported antigen could be extracted." + ) + if categories.get("PAMP with no regex match", 0): + findings.append( + f"{categories['PAMP with no regex match']} PAMP observations reached " + "antigen extraction but did not match any accepted regex." + ) + if totals["cells"] == 0 and totals["transitions"] == 0: + findings.append( + "No T-cell was ever created, so no state transition, effector action, " + "or memory write could happen." + ) + if totals["transitions_to_state"].get("3 - activated", 0): + findings.append( + f"{totals['transitions_to_state']['3 - activated']} activation " + "transition(s) reached state 3." + ) + if totals["transitions_to_state"].get("4 - effector", 0): + findings.append( + f"{totals['transitions_to_state']['4 - effector']} effector " + "transition(s) requested containment." + ) + if totals["memories"]: + findings.append( + f"{totals['memories']} memory cell(s) were stored for later reuse." + ) + if current_states.get("1 - antigen-recognized", 0): + findings.append( + f"{current_states['1 - antigen-recognized']} cell(s) are currently waiting " + "for co-stimulation." + ) + if current_states.get("3 - activated", 0): + findings.append( + f"{current_states['3 - activated']} cell(s) are currently waiting " + "for context." + ) + if report["sources"]["trace_enabled"] and not report["trace"]["rows"]: + findings.append( + "Decision tracing was enabled, but no trace rows were written." + ) + if not report["sources"]["trace_enabled"]: + findings.append( + "Decision trace was off for this run, so threshold-by-threshold " + "explanations are not available." + ) + return findings[:8] + + +def build_timelines( + observations: list[dict], + transitions: list[dict], + trace_rows: list[dict], +) -> dict: + observation_items = [ + {"ts": item["observed_at"], "signal": item["evidence_signal"]} + for item in observations + if item.get("observed_at") is not None + ] + transition_items = [ + {"ts": item["created_at"], "to_state": state_label(item.get("to_state"))} + for item in transitions + if item.get("created_at") is not None + ] + trace_items = [ + { + "ts": item.get("_ts"), + "stage": item.get("stage"), + "action": item.get("action"), + } + for item in trace_rows + if item.get("_ts") is not None + ] + + return { + "observations": bucket_items( + observation_items, + { + "PAMP observations": lambda item: item["signal"] == "PAMP", + "DAMP observations": lambda item: item["signal"] == "DAMP", + }, + ), + "transitions": bucket_items( + transition_items, + { + "recognized": lambda item: item["to_state"] == "1 - antigen-recognized", + "anergic": lambda item: item["to_state"] == "2 - anergic", + "activated": lambda item: item["to_state"] == "3 - activated", + "effector": lambda item: item["to_state"] == "4 - effector", + "memory": lambda item: item["to_state"] == "5 - memory", + }, + ), + "trace": bucket_items( + trace_items, + { + "co-stimulation": lambda item: item["stage"] == "co_stimulation", + "context": lambda item: item["stage"] == "context", + }, + ), + } + + +def bucket_items( + items: list[dict], series_predicates: dict[str, Any], bin_count: int = 36 +) -> dict: + timed = [item for item in items if item.get("ts") is not None] + if not timed: + return {} + + min_ts = min(float(item["ts"]) for item in timed) + max_ts = max(float(item["ts"]) for item in timed) + if max_ts <= min_ts: + max_ts = min_ts + 1.0 + bin_count = max(8, min(bin_count, 72)) + width = (max_ts - min_ts) / bin_count + if width <= 0: + width = 1.0 + + labels = [] + series = {name: [0] * bin_count for name in series_predicates} + for index in range(bin_count): + center = min_ts + ((index + 0.5) * width) + labels.append(ts_to_iso(center)) + + for item in timed: + idx = int((float(item["ts"]) - min_ts) / width) + idx = max(0, min(idx, bin_count - 1)) + for name, predicate in series_predicates.items(): + if predicate(item): + series[name][idx] += 1 + + return { + "labels": labels, + "series": series, + "min_ts": min_ts, + "max_ts": max_ts, + "width": width, + "bin_count": bin_count, + } + + +def build_report_payload( + run_output_dir: Path, + max_observations: int = 200, + max_log_lines: int = 400, + max_trace_rows: int = 200, +) -> dict: + run_output_dir = run_output_dir.expanduser().resolve() + db_path = run_output_dir / "t_cell" / "t_cell.sqlite" + log_path = run_output_dir / "t_cell.log" + trace_path = run_output_dir / "t_cell_trace.jsonl" + metadata_path = run_output_dir / "metadata" / "slips.yaml" + + db_records = load_db_records(db_path) + observations = db_records["observations"] + cells = db_records["cells"] + transitions = db_records["transitions"] + memories = db_records["memories"] + log_data = load_log_entries(log_path, max_log_lines) + trace_rows = load_trace_entries(trace_path) + config = load_yaml_config(metadata_path).get("t_cell", {}) + + transitions_by_observation: dict[int, list[dict]] = defaultdict(list) + for transition in transitions: + observation_id = transition.get("observation_id") + if observation_id is not None: + transitions_by_observation[int(observation_id)].append(transition) + + signal_counts = Counter() + evidence_type_counts = Counter() + observation_categories = Counter() + responsible_ip_counts = Counter() + related_profile_counts = Counter() + target_ip_counts = Counter() + antigen_counts = Counter() + unmatched_pamp_antigens = Counter() + matched_regex_counts = Counter() + + recent_observations = [] + for observation in observations: + signal_counts[observation["evidence_signal"]] += 1 + evidence_type_counts[ + (observation["evidence_type"], observation["evidence_signal"]) + ] += 1 + responsible_ip_counts[observation["responsible_ip"]] += 1 + + related_profile = observation_related_profile(observation) + target_ip = observation_target_ip(observation) + if related_profile: + related_profile_counts[related_profile] += 1 + if target_ip: + target_ip_counts[target_ip] += 1 + + category = categorize_observation(observation, transitions_by_observation) + observation_categories[category] += 1 + + for antigen in observation["antigens"]: + key = f"{antigen.get('regex_type')}:{antigen.get('value')}" + antigen_counts[key] += 1 + if ( + observation["evidence_signal"] == "PAMP" + and not observation["matched_regexes"] + ): + unmatched_pamp_antigens[key] += 1 + for match in observation["matched_regexes"]: + matched_regex_counts[ + f"{match.get('regex_type')}:{match.get('value')}" + ] += 1 + + recent_observations.append( + { + "ts": observation["observed_at"], + "wall": ts_to_iso(observation["observed_at"]), + "evidence_id": observation["evidence_id"], + "evidence_type": observation["evidence_type"], + "signal": observation["evidence_signal"], + "responsible_ip": observation["responsible_ip"], + "related_profile": related_profile, + "target_ip": target_ip, + "category": category, + "antigens": summarize_antigens(observation["antigens"]), + "matched_regexes": summarize_matched_regexes( + observation["matched_regexes"] + ), + "description": observation_description(observation), + "timewindow": observation["timewindow_number"], + "confidence": observation["confidence"], + } + ) + + recent_observations.sort( + key=lambda item: (float(item["ts"]), item["evidence_id"]), reverse=True + ) + + transition_reason_counts = Counter() + transition_path_counts = Counter() + transitions_to_state = Counter() + recent_transitions = [] + for transition in transitions: + transition_reason_counts[transition["reason"]] += 1 + from_label = state_label(transition.get("from_state")) + to_label = state_label(transition.get("to_state")) + transition_path_counts[f"{from_label} -> {to_label}"] += 1 + transitions_to_state[to_label] += 1 + recent_transitions.append( + { + "ts": transition["created_at"], + "wall": ts_to_iso(transition["created_at"]), + "cell_key": transition["cell_key"], + "responsible_ip": transition["responsible_ip"], + "regex_type": transition["regex_type"], + "antigen_value": transition["antigen_value"], + "evidence_id": transition["evidence_id"], + "from_state": from_label, + "to_state": to_label, + "reason": transition["reason"], + "matched_value": transition.get("matched_value") or "", + "scores": transition.get("scores") or {}, + } + ) + recent_transitions.sort(key=lambda item: item["ts"], reverse=True) + + current_state_counts = Counter() + recent_cells = [] + for cell in cells: + label = state_label(cell["state"]) + current_state_counts[label] += 1 + recent_cells.append( + { + "ts": cell["updated_at"], + "wall": ts_to_iso(cell["updated_at"]), + "cell_key": cell["cell_key"], + "responsible_ip": cell["responsible_ip"], + "state": label, + "state_class": state_class(cell["state"]), + "regex_type": cell["regex_type"], + "antigen_value": cell["antigen_value"], + "matched_value": cell.get("matched_value") or "", + "last_co_stimulation": cell.get("last_co_stimulation"), + "last_effector_score": cell.get("last_effector_score"), + "last_memory_score": cell.get("last_memory_score"), + "last_evidence_id": cell.get("last_evidence_id") or "", + } + ) + recent_cells.sort(key=lambda item: item["ts"], reverse=True) + + recent_memories = [] + for memory in memories: + recent_memories.append( + { + "ts": memory["updated_at"], + "wall": ts_to_iso(memory["updated_at"]), + "cell_key": memory["cell_key"], + "responsible_ip": memory["responsible_ip"], + "regex_type": memory["regex_type"], + "antigen_value": memory["antigen_value"], + "matched_value": memory["matched_value"], + "regex_hash": memory["regex_hash"], + "context": memory.get("context") or {}, + } + ) + recent_memories.sort(key=lambda item: item["ts"], reverse=True) + + trace_action_counts = Counter() + recent_trace_rows = [] + for entry in trace_rows: + trace_action_counts[entry.get("action") or "unknown"] += 1 + formula = entry.get("formula") or {} + recent_trace_rows.append( + { + "ts": entry.get("_ts"), + "wall": entry.get("ts") or ts_to_iso(entry.get("_ts")), + "stage": entry.get("stage") or "", + "action": entry.get("action") or "", + "from_state": entry.get("from_state") or "", + "to_state": entry.get("to_state") or "", + "responsible_ip": entry.get("responsible_ip") or "", + "candidate": entry.get("candidate") or {}, + "match": entry.get("match") or {}, + "formula": formula, + "score_summary": summarize_trace_formula(formula, entry.get("stage")), + } + ) + recent_trace_rows.sort( + key=lambda item: (item["ts"] is None, item["ts"] or 0.0), reverse=True + ) + + log_action_counts = Counter( + entry.get("action", "unknown") for entry in log_data["entries"] if entry + ) + recent_log_rows = [ + { + "ts": entry.get("ts"), + "wall": entry.get("wall") or "", + "action": entry.get("action", ""), + "signal": entry.get("signal", ""), + "evidence": entry.get("evidence", ""), + "responsible": entry.get("responsible", ""), + "raw": entry.get("raw", ""), + } + for entry in log_data["entries"][-max(1, max_log_lines) :] + ] + + report = { + "generated_at": now_iso(), + "run_output_dir": str(run_output_dir), + "sources": { + "db_path": str(db_path), + "log_path": str(log_path), + "trace_path": str(trace_path), + "metadata_path": str(metadata_path), + "trace_enabled": bool(trace_path.exists()), + "log_present": log_path.exists(), + "metadata_present": metadata_path.exists(), + }, + "config": { + "enabled": config.get("enabled"), + "log_verbosity": config.get("log_verbosity"), + "decision_trace_mode": config.get("decision_trace_mode"), + "related_lookback_seconds": config.get("related_lookback_seconds"), + "co_stimulation_threshold": config.get("co_stimulation_threshold"), + "effector_threshold": config.get("effector_threshold"), + "memory_threshold": config.get("memory_threshold"), + "anergy_ttl_seconds": config.get("anergy_ttl_seconds"), + "effector_cooldown_seconds": config.get("effector_cooldown_seconds"), + }, + "totals": { + "observations": len(observations), + "cells": len(cells), + "transitions": len(transitions), + "memories": len(memories), + "trace_rows": len(trace_rows), + "log_rows": len(log_data["entries"]), + "observations_with_antigens": sum( + 1 for item in observations if item["antigen_count"] > 0 + ), + "observations_with_matches": sum( + 1 for item in observations if item["matched_regexes"] + ), + "signals": dict(signal_counts), + "transitions_to_state": dict(transitions_to_state), + }, + "observation_categories": dict(observation_categories), + "cell_states": dict(current_state_counts), + "top_signals_by_type": [ + { + "evidence_type": evidence_type, + "signal": signal, + "count": count, + } + for (evidence_type, signal), count in evidence_type_counts.most_common(20) + ], + "top_responsible_ips": top_counts(responsible_ip_counts), + "top_related_profiles": top_counts(related_profile_counts), + "top_targets": top_counts(target_ip_counts), + "top_antigens": top_counts(antigen_counts, limit=20), + "top_unmatched_pamp_antigens": top_counts(unmatched_pamp_antigens, limit=20), + "top_matched_regexes": top_counts(matched_regex_counts, limit=20), + "transition_reasons": top_counts(transition_reason_counts, limit=20), + "transition_paths": top_counts(transition_path_counts, limit=20), + "trace_action_counts": top_counts(trace_action_counts, limit=20), + "log_action_counts": top_counts(log_action_counts, limit=20), + "recent_observations": recent_observations[: max(1, max_observations)], + "recent_transitions": recent_transitions[: max(1, max_observations)], + "recent_cells": recent_cells[: max(1, max_observations)], + "recent_memories": recent_memories[: max(1, max_observations)], + "trace": { + "rows": recent_trace_rows[: max(1, max_trace_rows)], + "total_rows": len(trace_rows), + }, + "log": { + "rows": recent_log_rows, + "tail_text": "\n".join(log_data["tail"]), + }, + } + report["timelines"] = build_timelines(observations, transitions, trace_rows) + report["findings"] = build_findings(report) + return report + + +def summarize_trace_formula(formula: dict, stage: str | None) -> str: + if not isinstance(formula, dict): + return "n/a" + if stage == "co_stimulation": + return ( + f"value={format_float(formula.get('value'))} / " + f"threshold={format_float(formula.get('threshold'))}" + ) + if stage == "context": + return ( + f"effector={format_float(formula.get('effector_score'))}/" + f"{format_float(formula.get('effector_threshold'))}, " + f"memory={format_float(formula.get('memory_score'))}/" + f"{format_float(formula.get('memory_threshold'))}" + ) + return "n/a" + + +def render_badge(text: str, css_class: str) -> str: + return f'{escape(text)}' + + +def render_counter_cards(report: dict) -> str: + totals = report["totals"] + signals = totals["signals"] + cards = [ + ("Observations", totals["observations"], "warm"), + ("PAMP", signals.get("PAMP", 0), "pamp"), + ("DAMP", signals.get("DAMP", 0), "damp"), + ("With Antigens", totals["observations_with_antigens"], "neutral"), + ("Regex Matches", totals["observations_with_matches"], "neutral"), + ("Cells", totals["cells"], "neutral"), + ("Transitions", totals["transitions"], "neutral"), + ("Memories", totals["memories"], "memory"), + ] + return "".join( + f""" +
+

{escape(label)}

+

{escape(str(value))}

+
+ """ + for label, value, css_class in cards + ) + + +def render_simple_table(columns: list[str], rows: list[dict], empty_text: str) -> str: + if not rows: + return f'

{escape(empty_text)}

' + head = "".join(f"{escape(column)}" for column in columns) + body_rows = [] + for row in rows: + body_cells = "".join( + f"{row.get(column, '')}" for column in columns + ) + body_rows.append(f"{body_cells}") + body = "".join(body_rows) + return ( + '
' + f"{head}{body}
" + ) + + +def render_sortable_observation_table(rows: list[dict]) -> str: + if not rows: + return '

No observations available.

' + + columns = [ + "Observed at", + "Category", + "Signal", + "Evidence", + "Responsible", + "Related profile", + "Target", + "Antigens", + "Matches", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + cells = [ + (escape(row["wall"]), row["ts"]), + (escape(row["category"]), row["category"]), + (render_badge(row["signal"], row["signal"].lower()), row["signal"]), + ( + escape(f"{row['evidence_type']} · {shorten(row['evidence_id'], 16)}"), + f"{row['evidence_type']} {row['evidence_id']}", + ), + (escape(row["responsible_ip"]), row["responsible_ip"]), + (escape(row["related_profile"]), row["related_profile"]), + (escape(row["target_ip"]), row["target_ip"]), + (escape(shorten(row["antigens"], 120)), row["antigens"]), + (escape(shorten(row["matched_regexes"], 120)), row["matched_regexes"]), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
" + "" + f"{head}{body}
" + ) + + +def render_svg_timeline(title: str, timeline: dict, series_order: list[str], color_map: dict[str, str]) -> str: + if not timeline: + return ( + f"

{escape(title)}

" + "

No timed data available.

" + ) + + labels = timeline["labels"] + series = timeline["series"] + width = 960 + height = 220 + padding_top = 18 + padding_bottom = 28 + padding_side = 20 + plot_width = width - (padding_side * 2) + plot_height = height - padding_top - padding_bottom + bars = [] + + max_total = 0 + for idx in range(len(labels)): + total = sum(series.get(name, [0] * len(labels))[idx] for name in series_order) + max_total = max(max_total, total) + max_total = max(max_total, 1) + bar_width = plot_width / max(1, len(labels)) + + for idx, label in enumerate(labels): + x = padding_side + (idx * bar_width) + stack_height = 0.0 + tooltip_lines = [label] + for name in series_order: + value = series.get(name, [0] * len(labels))[idx] + tooltip_lines.append(f"{name}: {value}") + if value <= 0: + continue + rect_height = (float(value) / float(max_total)) * plot_height + y = padding_top + (plot_height - stack_height - rect_height) + bars.append( + "" + f"{escape(' | '.join(tooltip_lines))}" + "" + ) + stack_height += rect_height + + legend = "".join( + f"
  • {escape(name)}
  • " + for name in series_order + ) + return f""" +
    +
    +

    {escape(title)}

    +

    {escape(ts_to_iso(timeline['min_ts']))} to {escape(ts_to_iso(timeline['max_ts']))} + · {int(round(timeline['width']))}s per bucket

    +
    + + + + {''.join(bars)} + +
      {legend}
    +
    + """ + + +def render_pretty_json(value: Any) -> str: + return escape(json.dumps(value, indent=2, sort_keys=True)) + + +def render_html(report: dict) -> str: + findings_html = "".join( + f"
  • {escape(item)}
  • " for item in report.get("findings", []) + ) or "
  • No notable findings.
  • " + + config_rows = [] + for key, value in report["config"].items(): + if value in (None, "", {}): + continue + config_rows.append( + { + "Key": escape(str(key)), + "Value": escape(str(value)), + } + ) + + signals_table = render_simple_table( + ["Signal", "Count", "Share"], + [ + { + "Signal": render_badge(signal, signal.lower()), + "Count": escape(str(count)), + "Share": escape( + f"{safe_div(count, report['totals']['observations']) * 100.0:.1f}%" + ), + } + for signal, count in sorted( + report["totals"]["signals"].items(), key=lambda item: item[0] + ) + ], + "No observations were stored.", + ) + + evidence_type_table = render_simple_table( + ["Evidence type", "Signal", "Count"], + [ + { + "Evidence type": escape(row["evidence_type"]), + "Signal": render_badge(row["signal"], row["signal"].lower()), + "Count": escape(str(row["count"])), + } + for row in report["top_signals_by_type"] + ], + "No evidence rows available.", + ) + + observation_table = render_sortable_observation_table( + report["recent_observations"] + ) + + transition_table = render_simple_table( + [ + "When", + "Path", + "Reason", + "Responsible", + "Cell", + "Evidence", + "Scores", + ], + [ + { + "When": escape(row["wall"]), + "Path": ( + f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " + f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}" + ), + "Reason": escape(row["reason"]), + "Responsible": escape(row["responsible_ip"]), + "Cell": escape(shorten(row["cell_key"], 54)), + "Evidence": escape(shorten(row["evidence_id"], 20)), + "Scores": f"
    show
    {render_pretty_json(row['scores'])}
    ", + } + for row in report["recent_transitions"] + ], + "No state transitions were recorded.", + ) + + cell_table = render_simple_table( + [ + "Updated", + "State", + "Responsible", + "Cell", + "Antigen", + "Matched value", + "Scores", + ], + [ + { + "Updated": escape(row["wall"]), + "State": render_badge(row["state"], row["state_class"]), + "Responsible": escape(row["responsible_ip"]), + "Cell": escape(shorten(row["cell_key"], 56)), + "Antigen": escape(f"{row['regex_type']}:{shorten(row['antigen_value'], 40)}"), + "Matched value": escape(shorten(row["matched_value"], 48)), + "Scores": escape( + ", ".join( + part + for part in [ + f"co={format_float(row['last_co_stimulation'])}" + if row["last_co_stimulation"] is not None + else "", + f"eff={format_float(row['last_effector_score'])}" + if row["last_effector_score"] is not None + else "", + f"mem={format_float(row['last_memory_score'])}" + if row["last_memory_score"] is not None + else "", + ] + if part + ) + or "n/a" + ), + } + for row in report["recent_cells"] + ], + "No cells are stored.", + ) + + memory_table = render_simple_table( + ["Updated", "Responsible", "Cell", "Regex", "Matched value", "Context"], + [ + { + "Updated": escape(row["wall"]), + "Responsible": escape(row["responsible_ip"]), + "Cell": escape(shorten(row["cell_key"], 56)), + "Regex": escape(shorten(row["regex_hash"], 24)), + "Matched value": escape(shorten(row["matched_value"], 40)), + "Context": ( + f"
    show
    {render_pretty_json(row['context'])}
    " + ), + } + for row in report["recent_memories"] + ], + "No memories are stored.", + ) + + trace_section = render_simple_table( + ["When", "Stage", "Action", "Path", "Responsible", "Candidate", "Scores"], + [ + { + "When": escape(row["wall"]), + "Stage": render_badge( + row["stage"] or "unknown", + f"trace-{(row['stage'] or 'unknown').replace('_', '-')}", + ), + "Action": escape(row["action"]), + "Path": escape(f"{row['from_state']} → {row['to_state']}"), + "Responsible": escape(row["responsible_ip"]), + "Candidate": escape( + f"{row['candidate'].get('regex_type', '')}:" + f"{shorten(row['candidate'].get('value', ''), 48)}" + ), + "Scores": ( + f"
    {escape(row['score_summary'])}" + f"
    {render_pretty_json(row['formula'])}
    " + ), + } + for row in report["trace"]["rows"] + ], + "No decision trace rows were stored.", + ) + + action_tables = { + "Top responsible IPs": render_simple_table( + ["Label", "Count"], + [ + {"Label": escape(row["label"]), "Count": escape(str(row["count"]))} + for row in report["top_responsible_ips"] + ], + "No responsible IP data.", + ), + "Top related profiles": render_simple_table( + ["Label", "Count"], + [ + {"Label": escape(row["label"]), "Count": escape(str(row["count"]))} + for row in report["top_related_profiles"] + ], + "No related profile data.", + ), + "Top targets": render_simple_table( + ["Label", "Count"], + [ + {"Label": escape(row["label"]), "Count": escape(str(row["count"]))} + for row in report["top_targets"] + ], + "No target data.", + ), + "Top antigens": render_simple_table( + ["Label", "Count"], + [ + {"Label": escape(row["label"]), "Count": escape(str(row["count"]))} + for row in report["top_antigens"] + ], + "No extracted antigens.", + ), + "Top unmatched PAMP antigens": render_simple_table( + ["Label", "Count"], + [ + {"Label": escape(row["label"]), "Count": escape(str(row["count"]))} + for row in report["top_unmatched_pamp_antigens"] + ], + "No unmatched PAMP antigens.", + ), + "Transition reasons": render_simple_table( + ["Label", "Count"], + [ + {"Label": escape(row["label"]), "Count": escape(str(row["count"]))} + for row in report["transition_reasons"] + ], + "No transition reasons available.", + ), + "Log action counts": render_simple_table( + ["Label", "Count"], + [ + {"Label": escape(row["label"]), "Count": escape(str(row["count"]))} + for row in report["log_action_counts"] + ], + "No module log actions available.", + ), + } + + action_sections = "".join( + f""" +
    +

    {escape(title)}

    + {html} +
    + """ + for title, html in action_tables.items() + ) + + config_section = render_simple_table( + ["Key", "Value"], + config_rows, + "No metadata configuration found.", + ) + + observation_timeline = render_svg_timeline( + "Observation Timeline", + report["timelines"]["observations"], + ["PAMP observations", "DAMP observations"], + { + "PAMP observations": SIGNAL_COLORS["PAMP"], + "DAMP observations": SIGNAL_COLORS["DAMP"], + }, + ) + transition_timeline = render_svg_timeline( + "Transition Timeline", + report["timelines"]["transitions"], + ["recognized", "anergic", "activated", "effector", "memory"], + { + "recognized": STATE_COLORS["state-recognized"], + "anergic": STATE_COLORS["state-anergic"], + "activated": STATE_COLORS["state-activated"], + "effector": STATE_COLORS["state-effector"], + "memory": STATE_COLORS["state-memory"], + }, + ) + trace_timeline = render_svg_timeline( + "Decision Trace Timeline", + report["timelines"]["trace"], + ["co-stimulation", "context"], + TRACE_STAGE_COLORS, + ) + + return f""" + + + + + T Cell Report + + + +
    +
    +

    T Cell HTML Report

    +
    +

    T Cell Run Report

    +

    Static analysis of observations, signals, transitions, memories, and optional decision traces. Generated at {escape(report['generated_at'])}

    +
    +
    +

    Run Output

    {escape(report['run_output_dir'])}
    +

    Database

    {escape(report['sources']['db_path'])}
    +

    Module Log

    {escape(report['sources']['log_path'])}
    +

    Decision Trace

    {escape(report['sources']['trace_path'])}
    +
    +
    + +
    +
    +

    Quick Summary

    +
    + {render_counter_cards(report)} +
    +
    +
    +

    Run Findings

    +
      {findings_html}
    +
    +
    + +
    + {observation_timeline} + {transition_timeline} + {trace_timeline} +
    + +
    +
    +

    Signals

    + {signals_table} +
    +
    +

    Evidence Types

    + {evidence_type_table} +
    +
    + +
    + {action_sections} +
    + +
    +

    Transitions

    + {transition_table} +
    + +
    +
    +

    Current Cells

    + {cell_table} +
    +
    +

    Stored Memories

    + {memory_table} +
    +
    + +
    +

    Decision Trace

    +

    If decision tracing was off for the run, this section will stay empty even when the rest of the report is populated.

    + {trace_section} +
    + +
    +

    Recent Observations

    +

    These rows come from the T Cell SQLite DB, so they remain available even when module log verbosity was low. Click a column header to sort.

    + {observation_table} +
    + + +
    + + + +""" + + +def state_class_name(label: str) -> str: + mapping = {value: STATE_CLASS[key] for key, value in STATE_LABELS.items()} + return mapping.get(label, "state-unknown") + + +def write_report(run_output_dir: Path, output_html: Path, args: argparse.Namespace) -> Path: + report = build_report_payload( + run_output_dir, + max_observations=args.max_observations, + max_log_lines=args.max_log_lines, + max_trace_rows=args.max_trace_rows, + ) + output_html.parent.mkdir(parents=True, exist_ok=True) + output_html.write_text(render_html(report), encoding="utf-8") + return output_html + + +def main() -> int: + args = parse_args() + run_output_dir = Path(args.run_output_dir).expanduser().resolve() + output_html = ( + Path(args.out).expanduser().resolve() + if args.out + else run_output_dir / "t_cell_report.html" + ) + report_path = write_report(run_output_dir, output_html, args) + print(f"Report written to: {report_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3b7fd26e2cc74780a2de8e3e38e10b8719263b28 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:37 +0000 Subject: [PATCH 0155/1100] feat: add offline HTML report generator for T Cell module analysis --- docs/t_cell_module.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index af8e2a444b..1ccf735416 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -418,6 +418,41 @@ Performance note: - trace mode performs extra observation lookups and extra file writes, so it should be treated as a verification feature, not the normal default path +### Offline HTML Report + +The module includes a separate offline report generator: + +```bash +./venv/bin/python modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ +``` + +By default it writes: + +```text +/t_cell_report.html +``` + +The report is static and self-contained. It reads the T Cell SQLite DB as the +primary source, then enriches the page with `t_cell.log` and +`t_cell_trace.jsonl` when those files exist. This means: + +- it still explains the run when `log_verbosity` is `1` +- it gains richer per-evidence detail when `log_verbosity` is `2` or `3` +- it gains threshold-by-threshold explanations when decision tracing is enabled + +The page focuses on the run itself, including: + +- total `PAMP` and `DAMP` observations +- evidence type mix +- extracted antigens and matched regexes +- current cells and their states +- transition reasons and state-path counts +- memories stored so far +- observation, transition, and trace timelines +- a sortable Recent Observations table at the bottom of the page +- a compact, collapsed configuration snapshot at the very end + Color mapping: - `0 - mature` -> cyan From 18e983ac513fa7a04c871fdb6947db53a6129f59 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:43 +0000 Subject: [PATCH 0156/1100] feat: add instructions for offline HTML report generation in T Cell module --- modules/t_cell/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index 3473befe02..db0074120d 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -38,6 +38,28 @@ Artifacts: The configured trace path is always forced under the selected run output directory. - module DB: `/t_cell/t_cell.sqlite` +- offline HTML report: `/t_cell_report.html` + +## Local HTML Report + +Use the included offline report generator to build a static HTML page from a +completed or running Slips output directory: + +```bash +./venv/bin/python modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ +``` + +By default it writes: + +```text +output//t_cell_report.html +``` + +The report reads the T Cell SQLite DB first, then enriches the page with the +module log and decision trace when those files exist. That means it still gives +useful summaries when `log_verbosity` is `1` or `2`, and becomes more detailed +when verbosity `3` or decision tracing is enabled. See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, configuration, formulas, and DB schema. From 0428b3e9761718f2ef088217c9772bb8cbd704f0 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:53 +0000 Subject: [PATCH 0157/1100] feat: add unit tests for T Cell report generation and HTML rendering --- .../modules/t_cell/test_analyze_t_cell.py | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 tests/unit/modules/t_cell/test_analyze_t_cell.py diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py new file mode 100644 index 0000000000..cecabf2fb6 --- /dev/null +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -0,0 +1,330 @@ +# SPDX-FileCopyrightText: 2026 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +from pathlib import Path +from unittest.mock import Mock + +from modules.t_cell.analyze_t_cell import build_report_payload, render_html +from slips_files.core.database.sqlite_db.t_cell_db import TCellStorage + + +def _build_storage(run_dir: Path) -> TCellStorage: + conf = Mock() + conf.t_cell_store_dir = Mock(return_value="output/t_cell") + conf.t_cell_persistent_store_dir = Mock(return_value="") + return TCellStorage(Mock(), conf, str(run_dir), 12345) + + +def _raw_evidence( + evidence_id: str, + evidence_type: str, + signal: str, + related_profile_ip: str, + attacker_ip: str, + victim_ip: str, + description: str, +) -> dict: + return { + "evidence_type": evidence_type, + "description": description, + "attacker": { + "direction": "SRC", + "ioc_type": "IP", + "value": attacker_ip, + }, + "victim": { + "direction": "DST", + "ioc_type": "IP", + "value": victim_ip, + }, + "profile": {"ip": related_profile_ip}, + "timewindow": {"number": 1}, + "uid": [], + "timestamp": "2026/03/21 09:22:37.000000+0000", + "interface": "eno1", + "id": evidence_id, + "confidence": 1.0, + "threat_level": "HIGH", + "evidence_signal": signal, + } + + +def test_build_report_payload_and_html(tmp_path): + run_dir = tmp_path / "run-output" + (run_dir / "metadata").mkdir(parents=True) + storage = _build_storage(run_dir) + + damp_observation_id = storage.insert_observation( + { + "evidence_id": "damp-1", + "evidence_type": "HTTP_TRAFFIC", + "evidence_signal": "DAMP", + "profile_ip": "2001:db8::5", + "timewindow_number": 1, + "timestamp": "2026/03/21 09:22:37.000000+0000", + "observed_at": 1000.0, + "confidence": 0.9, + "threat_level": "medium", + "threat_level_value": 0.5, + "interface": "eno1", + "uids": ["uid-damp-1"], + "antigen_count": 2, + "antigens": [ + {"regex_type": "dns_domain", "value": "rdap.db.ripe.net"}, + {"regex_type": "uri", "value": "/ip/5.161.194.92"}, + ], + "matched_regexes": [], + "raw_evidence": _raw_evidence( + "damp-1", + "HTTP_TRAFFIC", + "DAMP", + "2001:db8::5", + "2001:db8::5", + "2001:67c:2e8:22::c100:697", + "RDAP lookup over HTTP", + ), + } + ) + + pamp_observation_id = storage.insert_observation( + { + "evidence_id": "pamp-1", + "evidence_type": "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "evidence_signal": "PAMP", + "profile_ip": "203.0.113.90", + "timewindow_number": 2, + "timestamp": "2026/03/21 09:23:37.000000+0000", + "observed_at": 2000.0, + "confidence": 1.0, + "threat_level": "high", + "threat_level_value": 0.8, + "interface": "eno1", + "uids": ["uid-pamp-1"], + "antigen_count": 1, + "antigens": [ + {"regex_type": "dns_domain", "value": "bad.example.com"} + ], + "matched_regexes": [ + { + "regex_type": "dns_domain", + "value": "bad.example.com", + "regex_hash": "regex-hash-1", + "regex": r"^bad\.example\.com$", + "created_at": 1990.0, + "specificity": 1.0, + } + ], + "raw_evidence": _raw_evidence( + "pamp-1", + "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "PAMP", + "147.32.80.37", + "203.0.113.90", + "147.32.80.37", + "Known malicious domain", + ), + } + ) + + cell_key = "203.0.113.90|dns_domain|bad.example.com" + storage.upsert_cell( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "state": 5, + "state_name": "5 - memory", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": pamp_observation_id, + "last_evidence_id": "pamp-1", + "last_transition_at": 2000.3, + "last_co_stimulation": 0.91, + "last_effector_score": 0.33, + "last_memory_score": 0.78, + "context": {"novelty_score": 0, "recent_pressure": 0.42}, + "created_at": 2000.0, + "updated_at": 2000.3, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 0, + "to_state": 1, + "reason": "antigen_match", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"specificity": 1.0}, + "created_at": 2000.1, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 1, + "to_state": 3, + "reason": "co_stimulation_threshold_met", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"value": 0.91, "threshold": 0.65}, + "created_at": 2000.2, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 3, + "to_state": 5, + "reason": "context_memory", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"memory_score": 0.78, "memory_threshold": 0.60}, + "created_at": 2000.3, + } + ) + storage.upsert_memory( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "regex_hash": "regex-hash-1", + "regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "context": {"memory_score": 0.78, "recent_pressure": 0.42}, + "created_at": 2000.3, + "updated_at": 2000.3, + } + ) + + (run_dir / "metadata" / "slips.yaml").write_text( + "\n".join( + [ + "t_cell:", + " enabled: true", + " log_verbosity: 3", + " decision_trace_mode: transitions", + " co_stimulation_threshold: 0.65", + " effector_threshold: 0.70", + " memory_threshold: 0.60", + ] + ), + encoding="utf-8", + ) + (run_dir / "t_cell.log").write_text( + "\n".join( + [ + "T Cell module ready.", + "2026/03/21 09:22:37.597262 | action=antigens_extracted | evidence=HTTP_TRAFFIC | eid=damp-1 | signal=DAMP | profile=2001:db8::5 | responsible=2001:db8::5 | target=2001:67c:2e8:22::c100:697 | antigens=dns_domain:rdap.db.ripe.net, uri:/ip/5.161.194.92", + "2026/03/21 09:22:37.607926 | action=ignored_non_pamp | evidence=HTTP_TRAFFIC | eid=damp-1 | signal=DAMP | profile=2001:db8::5 | responsible=2001:db8::5 | target=2001:67c:2e8:22::c100:697", + "2026/03/21 09:23:37.607926 | action=memory_stored | state=5 - memory | evidence=THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN | eid=pamp-1 | signal=PAMP | profile=147.32.80.37 | responsible=203.0.113.90 | target=147.32.80.37 | cell=203.0.113.90|dns_domain|bad.example.com | regex=regex-hash-1 | value=bad.example.com", + ] + ), + encoding="utf-8", + ) + (run_dir / "t_cell_trace.jsonl").write_text( + "\n".join( + [ + json.dumps( + { + "ts": "2026/03/21 09:23:37.200000+0000", + "stage": "co_stimulation", + "action": "co_stimulation_threshold_met", + "from_state": "1 - antigen-recognized", + "to_state": "3 - activated", + "responsible_ip": "203.0.113.90", + "candidate": { + "regex_type": "dns_domain", + "value": "bad.example.com", + }, + "formula": { + "value": 0.91, + "threshold": 0.65, + "components": { + "related_pamps": {"count": 1}, + }, + }, + } + ), + json.dumps( + { + "ts": "2026/03/21 09:23:37.300000+0000", + "stage": "context", + "action": "context_memory", + "from_state": "3 - activated", + "to_state": "5 - memory", + "responsible_ip": "203.0.113.90", + "candidate": { + "regex_type": "dns_domain", + "value": "bad.example.com", + }, + "formula": { + "effector_score": 0.33, + "effector_threshold": 0.70, + "memory_score": 0.78, + "memory_threshold": 0.60, + }, + } + ), + ] + ), + encoding="utf-8", + ) + + payload = build_report_payload(run_dir, max_observations=50, max_log_lines=50, max_trace_rows=50) + + assert payload["totals"]["observations"] == 2 + assert payload["totals"]["signals"] == {"DAMP": 1, "PAMP": 1} + assert payload["totals"]["transitions"] == 3 + assert payload["totals"]["memories"] == 1 + assert payload["cell_states"] == {"5 - memory": 1} + assert payload["sources"]["trace_enabled"] is True + assert payload["trace"]["total_rows"] == 2 + assert payload["recent_observations"][0]["category"] == "PAMP with regex match" + assert any( + row["category"] == "DAMP with extracted antigens" + for row in payload["recent_observations"] + ) + assert payload["top_responsible_ips"][0]["label"] == "2001:db8::5" + + html = render_html(payload) + + assert "T Cell Report" in html + assert "T Cell Run Report" in html + assert "Run Findings" in html + assert "Quick Summary" in html + assert "Decision Trace" in html + assert "Module Log Tail" not in html + assert "data-sortable-table='recent-observations'" in html + assert "Click a column header to sort." in html + assert html.index("Recent Observations") < html.index("Run configuration snapshot") + assert "co_stimulation_threshold_met" in html + assert "context_memory" in html + assert "bad.example.com" in html + assert "DAMP with extracted antigens" in html + assert "PAMP with regex match" in html + + storage.close() From 52c3ca33279f9f302f6493c911c37059fb4a77f9 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:10 +0000 Subject: [PATCH 0158/1100] feat: add Mermaid state diagram for T Cell state machine and enhance report details --- docs/t_cell_module.md | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 1ccf735416..6800211579 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -93,6 +93,58 @@ The persisted states are: - `4 - effector` - `5 - memory` +Mermaid state diagram: + +```mermaid +stateDiagram-v2 + [*] --> S0 : new cell + + state "0 - mature" as S0 + state "1 - antigen-recognized" as S1 + state "2 - anergic" as S2 + state "3 - activated" as S3 + state "4 - effector" as S4 + state "5 - memory" as S5 + + S0 --> S1 : PAMP + antigen extracted\n+ accepted regex match + S0 --> S2 : PAMP + antigen extracted\n+ no regex match + S0 --> S0 : DAMP only or\nno antigen extracted + + S2 --> S0 : anergy TTL expired + + S1 --> S3 : co-stimulation >= threshold\nwithin 1 Slips TW + S1 --> S1 : re-evaluate on later evidence\nwhile below threshold + S1 --> S2 : co-stimulation timeout\nafter 1 Slips TW + + S3 --> S4 : context says novel + intense + S3 --> S5 : context says familiar + cooling down + S3 --> S3 : re-evaluate on later evidence\nwhile undecided + S3 --> S0 : context timeout\nafter 1 Slips TW + + S5 --> S5 : later matching evidence retained + S4 --> S4 : repeated hits gated by\neffector cooldown + + note right of S0 + DAMP observations are stored as danger signals. + They do not perform antigen recognition + and do not create a new cell by themselves. + end note + + note right of S1 + Co-stimulation combines: + current PAMP confidence + related PAMP count + weighted PAMP+DAMP danger + for the same responsible IP. + end note + + note right of S3 + Context uses the same mixed pressure model + to decide whether to contain now + or store memory for later. + end note +``` + The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. @@ -445,12 +497,14 @@ The page focuses on the run itself, including: - total `PAMP` and `DAMP` observations - evidence type mix +- a rendered T-cell state-machine graph with per-state and per-transition counts - extracted antigens and matched regexes - current cells and their states - transition reasons and state-path counts - memories stored so far - observation, transition, and trace timelines - a sortable Recent Observations table at the bottom of the page +- a sortable Transitions table that defaults to grouping rows by T cell - a compact, collapsed configuration snapshot at the very end Color mapping: From 4f028965830e9c878e5168eda370a7b55dc0a7a4 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:17 +0000 Subject: [PATCH 0159/1100] feat: enhance LLMBackend to support configurable HTTP connection pool size --- modules/llm/llm.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/llm/llm.py b/modules/llm/llm.py index 5f71e2a01a..7982c7404b 100644 --- a/modules/llm/llm.py +++ b/modules/llm/llm.py @@ -114,11 +114,12 @@ def _resolve_api_key(data: dict) -> str | None: class LLMBackend: - def __init__(self, config: LLMBackendConfig): + def __init__(self, config: LLMBackendConfig, pool_maxsize: int = 2): self.config = config self.http = urllib3.PoolManager( cert_reqs="CERT_REQUIRED", ca_certs=certifi.where(), + maxsize=max(2, int(pool_maxsize)), ) def generate(self, request: dict) -> dict: @@ -345,11 +346,14 @@ def read_configuration(self): self.failed_backends[alias] = str(exc) def _create_backend(self, config: LLMBackendConfig) -> LLMBackend: + # Keep the reusable HTTP connection pool comfortably above the + # worker concurrency so busy runs do not spam pool-discard warnings. + pool_maxsize = max(2, self.worker_threads * 2) if config.provider == "openai": - return OpenAIBackend(config) + return OpenAIBackend(config, pool_maxsize=pool_maxsize) if config.provider == "anthropic": - return AnthropicBackend(config) - return OllamaBackend(config) + return AnthropicBackend(config, pool_maxsize=pool_maxsize) + return OllamaBackend(config, pool_maxsize=pool_maxsize) @staticmethod def _empty_available_backends_registry() -> dict: From 31092fc199122b7001efa3f3bf301d8ac12f7a5e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:24 +0000 Subject: [PATCH 0160/1100] feat: add sortable transition table and state machine graph to T Cell report --- modules/t_cell/analyze_t_cell.py | 288 ++++++++++++++++++++++++++++--- 1 file changed, 261 insertions(+), 27 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index 2acf246ccc..55fb3c95e6 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -709,12 +709,22 @@ def build_report_payload( "evidence_id": transition["evidence_id"], "from_state": from_label, "to_state": to_label, + "from_state_order": transition.get("from_state", -1), + "to_state_order": transition.get("to_state", -1), "reason": transition["reason"], "matched_value": transition.get("matched_value") or "", "scores": transition.get("scores") or {}, } ) - recent_transitions.sort(key=lambda item: item["ts"], reverse=True) + recent_transitions.sort( + key=lambda item: ( + item["cell_key"].lower(), + float(item["ts"]), + int(item["from_state_order"]), + int(item["to_state_order"]), + item["evidence_id"], + ) + ) current_state_counts = Counter() recent_cells = [] @@ -995,6 +1005,67 @@ def render_sortable_observation_table(rows: list[dict]) -> str: ) +def render_sortable_transition_table(rows: list[dict]) -> str: + if not rows: + return '

    No state transitions were recorded.

    ' + + columns = [ + "When", + "Path", + "Reason", + "Responsible", + "T Cell", + "Evidence", + "Scores", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + score_summary = ", ".join( + f"{key}={value}" for key, value in sorted((row["scores"] or {}).items()) + ) or "n/a" + cells = [ + (escape(row["wall"]), row["ts"]), + ( + f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " + f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}", + f"{row['from_state_order']:02d}->{row['to_state_order']:02d}", + ), + (escape(row["reason"]), row["reason"]), + (escape(row["responsible_ip"]), row["responsible_ip"]), + (escape(shorten(row["cell_key"], 54)), row["cell_key"]), + (escape(shorten(row["evidence_id"], 20)), row["evidence_id"]), + ( + f"
    show
    {render_pretty_json(row['scores'])}
    ", + score_summary, + ), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
    " + "" + f"{head}{body}
    " + ) + + def render_svg_timeline(title: str, timeline: dict, series_order: list[str], color_map: dict[str, str]) -> str: if not timeline: return ( @@ -1062,6 +1133,189 @@ def render_svg_timeline(title: str, timeline: dict, series_order: list[str], col """ +def hex_to_rgba(hex_color: str, alpha: float) -> str: + color = hex_color.lstrip("#") + if len(color) != 6: + return f"rgba(31, 41, 55, {alpha})" + red = int(color[0:2], 16) + green = int(color[2:4], 16) + blue = int(color[4:6], 16) + return f"rgba({red}, {green}, {blue}, {alpha})" + + +def render_state_machine_graph(report: dict) -> str: + node_layout = { + 0: {"x": 40, "y": 122}, + 1: {"x": 320, "y": 44}, + 2: {"x": 320, "y": 244}, + 3: {"x": 600, "y": 122}, + 4: {"x": 880, "y": 30}, + 5: {"x": 880, "y": 214}, + } + node_width = 210 + node_height = 68 + transition_counts = { + row["label"]: row["count"] for row in report.get("transition_paths", []) + } + current_state_counts = report.get("cell_states", {}) + + edges = [ + { + "from": 0, + "to": 1, + "trigger": "regex match", + "path": "M 250 156 C 275 156, 286 120, 320 104", + "label_x": 272, + "label_y": 116, + }, + { + "from": 0, + "to": 2, + "trigger": "no regex", + "path": "M 250 156 C 275 156, 286 286, 320 278", + "label_x": 268, + "label_y": 252, + }, + { + "from": 2, + "to": 0, + "trigger": "anergy TTL", + "path": "M 320 306 C 248 338, 178 322, 146 190", + "label_x": 182, + "label_y": 330, + }, + { + "from": 1, + "to": 1, + "trigger": "wait", + "path": "M 392 44 C 350 4, 502 4, 460 44", + "label_x": 426, + "label_y": 12, + }, + { + "from": 1, + "to": 3, + "trigger": "co-stimulation", + "path": "M 530 78 L 600 156", + "label_x": 542, + "label_y": 94, + }, + { + "from": 1, + "to": 2, + "trigger": "timeout", + "path": "M 425 112 L 425 244", + "label_x": 438, + "label_y": 184, + }, + { + "from": 3, + "to": 3, + "trigger": "wait", + "path": "M 672 122 C 630 82, 782 82, 740 122", + "label_x": 706, + "label_y": 90, + }, + { + "from": 3, + "to": 4, + "trigger": "contain", + "path": "M 810 144 L 880 86", + "label_x": 828, + "label_y": 112, + }, + { + "from": 3, + "to": 5, + "trigger": "remember", + "path": "M 810 168 L 880 248", + "label_x": 824, + "label_y": 214, + }, + { + "from": 3, + "to": 0, + "trigger": "context timeout", + "path": "M 600 156 C 536 236, 286 236, 250 156", + "label_x": 430, + "label_y": 260, + }, + { + "from": 4, + "to": 4, + "trigger": "cooldown", + "path": "M 952 30 C 914 -8, 1088 -8, 1050 30", + "label_x": 1000, + "label_y": 2, + }, + { + "from": 5, + "to": 5, + "trigger": "retained", + "path": "M 952 282 C 914 320, 1088 320, 1050 282", + "label_x": 998, + "label_y": 334, + }, + ] + + node_svg = [] + for state_id, label in STATE_LABELS.items(): + node = node_layout[state_id] + color = STATE_COLORS[state_class(state_id)] + count = current_state_counts.get(label, 0) + node_svg.append( + f""" + + + {escape(label)} + current cells: {count} + + """ + ) + + edge_svg = [] + for edge in edges: + from_label = STATE_LABELS[edge["from"]] + to_label = STATE_LABELS[edge["to"]] + path_key = f"{from_label} -> {to_label}" + count = int(transition_counts.get(path_key, 0)) + active = count > 0 + stroke = STATE_COLORS[state_class(edge["to"])] + edge_svg.append( + f""" + + + + {escape(edge['trigger'])} · {count} + + + """ + ) + + return f""" +
    +
    +

    T Cell State Machine

    +

    Node badges show current cells in each state. Arrow labels show how many times each transition happened in this run.

    +
    + + + + + + + + {''.join(edge_svg)} + {''.join(node_svg)} + +
    + """ + + def render_pretty_json(value: Any) -> str: return escape(json.dumps(value, indent=2, sort_keys=True)) @@ -1116,32 +1370,8 @@ def render_html(report: dict) -> str: report["recent_observations"] ) - transition_table = render_simple_table( - [ - "When", - "Path", - "Reason", - "Responsible", - "Cell", - "Evidence", - "Scores", - ], - [ - { - "When": escape(row["wall"]), - "Path": ( - f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " - f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}" - ), - "Reason": escape(row["reason"]), - "Responsible": escape(row["responsible_ip"]), - "Cell": escape(shorten(row["cell_key"], 54)), - "Evidence": escape(shorten(row["evidence_id"], 20)), - "Scores": f"
    show
    {render_pretty_json(row['scores'])}
    ", - } - for row in report["recent_transitions"] - ], - "No state transitions were recorded.", + transition_table = render_sortable_transition_table( + report["recent_transitions"] ) cell_table = render_simple_table( @@ -1332,6 +1562,7 @@ def render_html(report: dict) -> str: ["co-stimulation", "context"], TRACE_STAGE_COLORS, ) + state_machine_graph = render_state_machine_graph(report) return f""" @@ -1668,6 +1899,8 @@ def render_html(report: dict) -> str: {trace_timeline} + {state_machine_graph} +

    Signals

    @@ -1685,6 +1918,7 @@ def render_html(report: dict) -> str:

    Transitions

    +

    Click a column header to sort. Default order groups rows by T cell so each cell's path stays together.

    {transition_table}
    From 80a054e1f55ea172385fd19549eeb3b18d238184 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:30 +0000 Subject: [PATCH 0161/1100] feat: add state machine diagram for T Cell module in README --- modules/t_cell/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index db0074120d..c8bcb53322 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -31,6 +31,31 @@ Main behavior: - containment reuses the existing `new_blocking` payload shape - all T Cell state is stored in its own SQLite DB and log file +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> S0 + + state "0 - mature" as S0 + state "1 - antigen-recognized" as S1 + state "2 - anergic" as S2 + state "3 - activated" as S3 + state "4 - effector" as S4 + state "5 - memory" as S5 + + S0 --> S1 : PAMP + antigen + regex match + S0 --> S2 : PAMP + antigen + no regex match + S0 --> S0 : DAMP only or no antigen + S2 --> S0 : anergy TTL expired + S1 --> S3 : co-stimulation threshold met + S1 --> S2 : co-stimulation timeout + S3 --> S4 : context -> contain + S3 --> S5 : context -> remember + S3 --> S0 : context timeout + S5 --> S5 : later matching evidence retained +``` + Artifacts: - module log: `output/t_cell.log` From 85ab939f89add09c240806809fd21505e8715dfd Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:35 +0000 Subject: [PATCH 0162/1100] feat: add test for LLMBackend pool size scaling with worker threads --- tests/unit/modules/llm/test_llm.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/modules/llm/test_llm.py b/tests/unit/modules/llm/test_llm.py index 6fff9eb2bb..0391b05666 100644 --- a/tests/unit/modules/llm/test_llm.py +++ b/tests/unit/modules/llm/test_llm.py @@ -277,3 +277,21 @@ def test_ollama_backend_parses_response(): assert response["usage"]["input_tokens"] == 9 assert response["usage"]["output_tokens"] == 11 assert response["usage"]["total_tokens"] == 20 + + +def test_llm_backend_pool_size_scales_with_worker_threads(): + llm = ModuleFactory().create_llm_obj() + llm.worker_threads = 3 + config = LLMBackendConfig.from_dict( + "local_qwen", + { + "provider": "ollama", + "model": "qwen2.5:3b", + "base_url": "http://127.0.0.1:11434", + }, + ) + + with patch("modules.llm.llm.urllib3.PoolManager") as mock_pool: + llm._create_backend(config) + + assert mock_pool.call_args.kwargs["maxsize"] == 6 From b915c28a7d7cb60cb094cbf72bd3fcb516d84487 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:43 +0000 Subject: [PATCH 0163/1100] feat: enhance report HTML output with T Cell state machine details and sortable transitions --- tests/unit/modules/t_cell/test_analyze_t_cell.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index cecabf2fb6..454a5fb29a 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -317,8 +317,14 @@ def test_build_report_payload_and_html(tmp_path): assert "Run Findings" in html assert "Quick Summary" in html assert "Decision Trace" in html + assert "T Cell State Machine" in html + assert "regex match" in html + assert "current cells: 1" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html + assert "data-sortable-table='recent-transitions'" in html + assert "data-default-sort-column='4'" in html + assert "Default order groups rows by T cell" in html assert "Click a column header to sort." in html assert html.index("Recent Observations") < html.index("Run configuration snapshot") assert "co_stimulation_threshold_met" in html From 9ebf3bbbf4af4ab47ab39f30b5a24172352557e0 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:09 +0000 Subject: [PATCH 0164/1100] fix: clarify DAMP evidence handling in T Cell module description --- docs/evidence_signals.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md index 544898b8a0..12b9cbbca8 100644 --- a/docs/evidence_signals.md +++ b/docs/evidence_signals.md @@ -6,7 +6,9 @@ The `T Cell` module consumes this same central field and only activates its state machine for antigen recognition from `PAMP` evidence. `DAMP` evidence is still stored by the module as an observation and contributes to the danger pressure used in T-cell co-stimulation and context calculations for the same -responsible IP, but it does not create cells or perform regex matching. See +responsible IP, and each new `DAMP` also reevaluates cells that are already +waiting on that responsible IP. `DAMP` does not create cells or perform regex +matching by itself. See [T Cell Module](t_cell_module.md) for the responder details. The supported values are: From ad0f153a6d62f60048a55503c7bf16ebd5ad6d29 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:15 +0000 Subject: [PATCH 0165/1100] feat: add link to T Cell offline report generation in README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a19e9a4ac4..a2008e61ad 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,9 @@ We appreciate your contributions and thank you for helping to improve Slips! T Cell design and configuration: [docs/t_cell_module.md](docs/t_cell_module.md) +T Cell offline report generation and interpretation: +[docs/t_cell_module.md#offline-html-report](docs/t_cell_module.md#offline-html-report) + [Code docs](https://stratospherelinuxips.readthedocs.io/en/develop/code_documentation.html ) --- From cbd2fb26b3c4da321807d916a264c2fa7782e8bf Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:28 +0000 Subject: [PATCH 0166/1100] feat: enhance T Cell module documentation with DAMP handling and waiting states --- docs/t_cell_module.md | 88 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 6800211579..ab714f10be 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -6,7 +6,8 @@ accepted RegexGenerator regex corpus, and then escalates through a small state machine until it either becomes tolerant, publishes a containment request, or stores a memory snapshot for later reuse. `DAMP` observations do not perform antigen recognition, but they do raise the danger pressure used later in -co-stimulation and context decisions. +co-stimulation and context decisions and they now trigger reevaluation of +already waiting cells for the same responsible IP. The module is started by the normal Slips module loader and is enabled by default through `t_cell.enabled: true`. @@ -23,8 +24,8 @@ modules: 4. It matches those values against accepted regexes already stored by `RegexGenerator`. 5. It stores `DAMP` observations as responsible-IP danger signals and folds - them into co-stimulation and context pressure for later `PAMP` - reevaluations. + them into co-stimulation and context pressure, and `DAMP` arrivals also + trigger reevaluation of cells that are already waiting. 6. It computes co-stimulation and context scores. 7. It either becomes tolerant, activates, requests blocking, or stores memory. @@ -93,6 +94,16 @@ The persisted states are: - `4 - effector` - `5 - memory` +States `1 - antigen-recognized` and `3 - activated` can also carry an +explicit waiting substatus in the stored cell context: + +- `1 - antigen-recognized (waiting for co-stimulation)` +- `3 - activated (waiting for context)` + +This does not create new state numbers. It is an explicit runtime marker that +the cell is still in state `1` or `3`, but is currently waiting for the next +reevaluation. + Mermaid state diagram: ```mermaid @@ -113,12 +124,12 @@ stateDiagram-v2 S2 --> S0 : anergy TTL expired S1 --> S3 : co-stimulation >= threshold\nwithin 1 Slips TW - S1 --> S1 : re-evaluate on later evidence\nwhile below threshold + S1 --> S1 : re-evaluate on later PAMP or DAMP\nwhile below threshold S1 --> S2 : co-stimulation timeout\nafter 1 Slips TW S3 --> S4 : context says novel + intense S3 --> S5 : context says familiar + cooling down - S3 --> S3 : re-evaluate on later evidence\nwhile undecided + S3 --> S3 : re-evaluate on later PAMP or DAMP\nwhile undecided S3 --> S0 : context timeout\nafter 1 Slips TW S5 --> S5 : later matching evidence retained @@ -127,7 +138,8 @@ stateDiagram-v2 note right of S0 DAMP observations are stored as danger signals. They do not perform antigen recognition - and do not create a new cell by themselves. + and do not create a new cell by themselves, + but they do re-check waiting cells. end note note right of S1 @@ -149,11 +161,13 @@ The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. 2. The module stores one observation row in its own SQLite DB. -3. If the evidence signal is not `PAMP`, the module logs `ignored_non_pamp` - and stops for that evidence after storing the observation. -4. Stored `DAMP` observations do not create or match cells, but they are kept - as danger inputs and are included in the next co-stimulation or context - evaluation for the same responsible IP. +3. If the evidence signal is `DAMP`, the module stores the observation, + reevaluates any waiting cells for the same responsible IP, logs + `damp_reverification`, and does not attempt antigen recognition from that + evidence. +4. If the evidence signal is neither `PAMP` nor `DAMP`, the module logs + `ignored_non_pamp` and stops for that evidence after storing the + observation. 5. If no structured antigen can be extracted, the module logs `no_antigen_extracted` and stops for that evidence. 6. For each antigen candidate, the module loads or creates the cell in @@ -166,11 +180,13 @@ The runtime flow is: a new `anergic_until`. 10. If a regex matches, the cell goes `0 -> 1` and stores the chosen regex metadata. -11. The module computes co-stimulation from the current `PAMP`, related - `PAMP`s, and stored `DAMP` danger pressure for the same responsible IP. +11. The module computes co-stimulation from the recognized `PAMP` + confidence, related `PAMP`s, and stored `DAMP` danger pressure for the + same responsible IP. 12. If co-stimulation crosses the configured threshold, the cell goes `1 -> 3`. 13. If co-stimulation stays below threshold, the cell can wait in - `1 - antigen-recognized` for at most one configured Slips time window. + `1 - antigen-recognized` for at most one configured Slips time window, + with the cell explicitly marked as waiting for co-stimulation. 14. If that one-time-window wait expires without enough co-stimulation, the cell goes `1 -> 2 - anergic`. 15. In state `3`, the module computes context signals from the same mixed @@ -182,6 +198,11 @@ The runtime flow is: 18. If state `3` cannot decide effector or memory within one configured Slips time window, the cell goes `3 -> 0 - mature`. +Both waiting states are reevaluated on later matching `PAMP`s and on later +`DAMP` observations for the same responsible IP. `DAMP` still does not create +or match a new cell by itself; it only re-checks cells that already exist and +are waiting. + State `4` publishes the existing `new_blocking` payload for the responsible IP when blocking support is present. If blocking or ARP poisoning modules are not running, the module can simulate the effector decision and log the exact @@ -485,6 +506,9 @@ By default it writes: /t_cell_report.html ``` +You can then open that HTML file directly in any browser. If you want a +different output filename, pass `--out `. + The report is static and self-contained. It reads the T Cell SQLite DB as the primary source, then enriches the page with `t_cell.log` and `t_cell_trace.jsonl` when those files exist. This means: @@ -493,6 +517,10 @@ primary source, then enriches the page with `t_cell.log` and - it gains richer per-evidence detail when `log_verbosity` is `2` or `3` - it gains threshold-by-threshold explanations when decision tracing is enabled +Example report screenshot from a real run: + +![T Cell HTML report overview](images/t_cell/t_cell_report_overview.png) + The page focuses on the run itself, including: - total `PAMP` and `DAMP` observations @@ -507,6 +535,38 @@ The page focuses on the run itself, including: - a sortable Transitions table that defaults to grouping rows by T cell - a compact, collapsed configuration snapshot at the very end +How to read the report: + +- **Quick Summary** and **Run Findings** tell you first whether the module saw + mostly `PAMP` or `DAMP`, whether cells were created at all, and whether the + run stalled because no supported antigen could be extracted. +- **Observation / Transition timelines** show when pressure and state changes + happened over time. This is the fastest way to see whether the module was + mostly idle, mostly collecting danger, or actively moving cells. +- **T Cell State Machine** overlays the abstract state machine with run data: + each node shows how many cells are currently in that state, and each arrow + shows how many times that transition happened in the run. +- **Signals**, **Evidence Types**, and the top-* panels show what fed the + danger model: which evidence classes dominated, which responsible IPs or + targets were involved most often, and which antigens or unmatched `PAMP` + values kept appearing. +- **Transitions** is the per-cell transition history. It is sortable and + defaults to grouping rows by T cell, so you can read one cell's path from + `0 - mature` onward without manually regrouping the table. +- **Current Cells** shows the cells that still exist now, their current state, + any explicit waiting substatus such as `waiting for co-stimulation` or + `waiting for context`, and the latest co-stimulation / effector / memory + scores that were stored on the cell. +- **Stored Memories** shows which cells have already reached + `5 - memory`, along with the saved context snapshot that will be reused + later. +- **Decision Trace** is the threshold-audit section. When enabled, it is where + you verify why a threshold passed by checking the weighted formula terms and + contributing evidence IDs. +- **Recent Observations** stays at the bottom as the raw sortable evidence + audit table. It is the best section to correlate what Slips generated with + what T Cell actually received and stored. + Color mapping: - `0 - mature` -> cyan From ba810203194c8566e37ba81948bccca02ff813b8 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:38 +0000 Subject: [PATCH 0167/1100] feat: add waiting state handling and sortable cell table to T Cell report --- modules/t_cell/analyze_t_cell.py | 166 +++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 41 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index 55fb3c95e6..86bf52cd86 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -58,6 +58,10 @@ } SIGNAL_COLORS = {"PAMP": "#c2410c", "DAMP": "#0369a1"} TRACE_STAGE_COLORS = {"co_stimulation": "#b45309", "context": "#7c3aed"} +WAITING_LABELS = { + "co_stimulation": "waiting for co-stimulation", + "context": "waiting for context", +} def parse_args() -> argparse.Namespace: @@ -155,6 +159,19 @@ def state_class(state: int | None) -> str: return STATE_CLASS.get(state, "state-unknown") +def cell_waiting_label(cell: dict | None) -> str: + context = (cell or {}).get("context") or {} + return WAITING_LABELS.get(context.get("waiting_for"), "") + + +def display_cell_state(cell: dict) -> str: + label = state_label(cell.get("state")) + waiting_label = cell_waiting_label(cell) + if waiting_label: + return f"{label} ({waiting_label})" + return label + + def shorten(value: Any, limit: int = 96) -> str: text = str(value or "") if len(text) <= limit: @@ -738,6 +755,7 @@ def build_report_payload( "cell_key": cell["cell_key"], "responsible_ip": cell["responsible_ip"], "state": label, + "state_display": display_cell_state(cell), "state_class": state_class(cell["state"]), "regex_type": cell["regex_type"], "antigen_value": cell["antigen_value"], @@ -746,6 +764,7 @@ def build_report_payload( "last_effector_score": cell.get("last_effector_score"), "last_memory_score": cell.get("last_memory_score"), "last_evidence_id": cell.get("last_evidence_id") or "", + "waiting_label": cell_waiting_label(cell), } ) recent_cells.sort(key=lambda item: item["ts"], reverse=True) @@ -947,6 +966,90 @@ def render_simple_table(columns: list[str], rows: list[dict], empty_text: str) - ) +def render_sortable_cell_table(rows: list[dict]) -> str: + if not rows: + return '

    No cells are stored.

    ' + + columns = [ + "Updated", + "State", + "Responsible", + "T Cell", + "Antigen", + "Matched value", + "Scores", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + score_parts = [ + f"co={format_float(row['last_co_stimulation'])}" + if row["last_co_stimulation"] is not None + else "", + f"eff={format_float(row['last_effector_score'])}" + if row["last_effector_score"] is not None + else "", + f"mem={format_float(row['last_memory_score'])}" + if row["last_memory_score"] is not None + else "", + ] + score_summary = ", ".join(part for part in score_parts if part) or "n/a" + waiting_html = "" + if row["waiting_label"]: + waiting_html = ( + f"
    {escape(row['waiting_label'])}
    " + ) + cells = [ + (escape(row["wall"]), row["ts"]), + ( + "
    " + f"{render_badge(row['state'], row['state_class'])}" + f"{waiting_html}" + "
    ", + row["state"], + ), + (escape(row["responsible_ip"]), row["responsible_ip"]), + ( + f"
    {escape(shorten(row['cell_key'], 72))}
    ", + row["cell_key"], + ), + ( + f"
    {escape(row['regex_type'])}:" + f"{escape(shorten(row['antigen_value'], 52))}
    ", + f"{row['regex_type']}:{row['antigen_value']}", + ), + ( + f"
    {escape(shorten(row['matched_value'], 52))}
    ", + row["matched_value"], + ), + (escape(score_summary), score_summary), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
    " + "" + f"{head}{body}
    " + ) + + def render_sortable_observation_table(rows: list[dict]) -> str: if not rows: return '

    No observations available.

    ' @@ -1374,47 +1477,7 @@ def render_html(report: dict) -> str: report["recent_transitions"] ) - cell_table = render_simple_table( - [ - "Updated", - "State", - "Responsible", - "Cell", - "Antigen", - "Matched value", - "Scores", - ], - [ - { - "Updated": escape(row["wall"]), - "State": render_badge(row["state"], row["state_class"]), - "Responsible": escape(row["responsible_ip"]), - "Cell": escape(shorten(row["cell_key"], 56)), - "Antigen": escape(f"{row['regex_type']}:{shorten(row['antigen_value'], 40)}"), - "Matched value": escape(shorten(row["matched_value"], 48)), - "Scores": escape( - ", ".join( - part - for part in [ - f"co={format_float(row['last_co_stimulation'])}" - if row["last_co_stimulation"] is not None - else "", - f"eff={format_float(row['last_effector_score'])}" - if row["last_effector_score"] is not None - else "", - f"mem={format_float(row['last_memory_score'])}" - if row["last_memory_score"] is not None - else "", - ] - if part - ) - or "n/a" - ), - } - for row in report["recent_cells"] - ], - "No cells are stored.", - ) + cell_table = render_sortable_cell_table(report["recent_cells"]) memory_table = render_simple_table( ["Updated", "Responsible", "Cell", "Regex", "Matched value", "Context"], @@ -1755,6 +1818,26 @@ def render_html(report: dict) -> str: .report-table tr:last-child td {{ border-bottom: none; }} + .cells-table {{ + min-width: 900px; + table-layout: auto; + }} + .cell-state-stack {{ + display: grid; + gap: 4px; + min-width: 0; + align-items: start; + }} + .cell-substate {{ + color: var(--muted); + font-size: 0.68rem; + line-height: 1.2; + }} + .cell-key {{ + line-height: 1.25; + overflow-wrap: anywhere; + word-break: break-word; + }} .sort-button {{ display: inline-flex; align-items: center; @@ -1925,6 +2008,7 @@ def render_html(report: dict) -> str:

    Current Cells

    +

    Click a column header to sort. Waiting cells keep the main state badge and show the wait condition underneath.

    {cell_table}
    From 34c8016fbac86335bfe0e52ff8e03b56371ed1ea Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:45 +0000 Subject: [PATCH 0168/1100] feat: enhance README with detailed T Cell behavior and report insights --- modules/t_cell/README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index c8bcb53322..144c9a21a4 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -18,14 +18,16 @@ Main behavior: - `evidence.profile.ip` is the related host context, while containment and T-cell ownership use the evidence's responsible IP - stored `DAMP` observations raise the danger pressure used by - co-stimulation and context for the same responsible IP + co-stimulation and context for the same responsible IP, and each new DAMP + reevaluates waiting cells on that responsible IP - optional decision tracing writes a separate JSONL audit file showing which evidence IDs contributed to threshold calculations - co-stimulation and context scores decide whether the cell becomes tolerant, activates, requests containment, or stores memory - state `1 - antigen-recognized` and state `3 - activated` can each wait for at most one configured Slips time window before timing out to `2 - anergic` - or `0 - mature` + or `0 - mature`; waiting cells are explicitly marked as + `waiting for co-stimulation` or `waiting for context` - once a cell reaches `5 - memory`, later matching evidence keeps it in memory without emitting repeated `memory_stored` actions - containment reuses the existing `new_blocking` payload shape @@ -49,9 +51,11 @@ stateDiagram-v2 S0 --> S0 : DAMP only or no antigen S2 --> S0 : anergy TTL expired S1 --> S3 : co-stimulation threshold met + S1 --> S1 : later PAMP or DAMP re-check S1 --> S2 : co-stimulation timeout S3 --> S4 : context -> contain S3 --> S5 : context -> remember + S3 --> S3 : later PAMP or DAMP re-check S3 --> S0 : context timeout S5 --> S5 : later matching evidence retained ``` @@ -81,10 +85,23 @@ By default it writes: output//t_cell_report.html ``` +Open that HTML file locally in a browser. If you want a different filename, +pass `--out `. + The report reads the T Cell SQLite DB first, then enriches the page with the module log and decision trace when those files exist. That means it still gives useful summaries when `log_verbosity` is `1` or `2`, and becomes more detailed when verbosity `3` or decision tracing is enabled. +What the report tells you: + +- whether the run was dominated by `PAMP`, `DAMP`, or both +- which evidence types, responsible IPs, targets, and antigens drove the run +- which T-cell state transitions happened and how many times +- which cells are currently waiting, activated, anergic, effector, or memory +- why thresholds were crossed when decision tracing was enabled +- which raw observations reached the T Cell module, even when log verbosity was + low + See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, configuration, formulas, and DB schema. From 72e7a882ce009a6a2167d9c0ae83461bc143e313 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:51 +0000 Subject: [PATCH 0169/1100] feat: implement waiting state handling and DAMP reevaluation in T Cell module --- modules/t_cell/t_cell.py | 560 ++++++++++++++++++++++++++++++--------- 1 file changed, 441 insertions(+), 119 deletions(-) diff --git a/modules/t_cell/t_cell.py b/modules/t_cell/t_cell.py index ded0c8fc56..7b9f420a09 100644 --- a/modules/t_cell/t_cell.py +++ b/modules/t_cell/t_cell.py @@ -57,6 +57,13 @@ TRACE_MODE_OFF = 0 TRACE_MODE_TRANSITIONS = 1 TRACE_MODE_ALL = 2 +CONTEXT_REMOVE = object() +WAITING_CO_STIMULATION = "co_stimulation" +WAITING_CONTEXT = "context" +WAITING_LABELS = { + WAITING_CO_STIMULATION: "waiting for co-stimulation", + WAITING_CONTEXT: "waiting for context", +} @dataclass(frozen=True) @@ -264,6 +271,27 @@ def _process_evidence_message(self, message: dict): ) matched_regexes = [] + if evidence.evidence_signal == EvidenceSignal.DAMP: + reevaluated_count = self._reevaluate_waiting_cells( + evidence=evidence, + observation_id=observation_id, + responsible_ip=responsible_ip, + now=now, + ) + self._log_event( + action="damp_reverification", + state=None, + evidence=evidence, + metrics={"reevaluated_cells": reevaluated_count}, + details=( + "stored DAMP danger and rechecked waiting cells for this " + "responsible IP" + ), + verbosity=LOG_VERBOSITY_DECISIONS, + ) + self._prune_observations(now) + return + if evidence.evidence_signal != EvidenceSignal.PAMP: self._log_event( action="ignored_non_pamp", @@ -364,10 +392,12 @@ def _process_candidate( now, last_observation_id=observation_id, last_evidence_id=evidence.id, - context={ - "reason": "no_regex_match_after_activation", - "observation_id": observation_id, - }, + ) + self._update_cell_context( + cell, + now, + reason="no_regex_match_after_activation", + observation_id=observation_id, ) self._log_event( action="no_regex_match", @@ -408,16 +438,348 @@ def _process_candidate( now, **match_updates, ) + cell = self._remember_match_context( + cell, + now, + observation_id, + evidence.id, + match, + ) if cell["state"] == STATE_MEMORY: - self._update_cell( + self._update_cell_context( cell, now, - context={ - "reason": "memory_retained", - "observation_id": observation_id, + reason="memory_retained", + observation_id=observation_id, + matched_regex_hash=match.regex_hash, + ) + self._log_event( + action="memory_retained", + state=STATE_MEMORY, + evidence=evidence, + cell=cell, + match=match, + details=( + "memory already exists for this cell; keeping the memory " + "state without storing a new memory event" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + return match + + return self._advance_cell_with_match( + cell=cell, + evidence=evidence, + observation_id=observation_id, + candidate=candidate, + match=match, + now=now, + responsible_ip=responsible_ip, + reference_observation_id=observation_id, + ) + + def _get_or_create_cell( + self, profile_ip: str, regex_type: str, antigen_value: str, now: float + ) -> dict: + cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) + cell = self.storage.get_cell(cell_key) + if cell: + return cell + + return { + "cell_key": cell_key, + "profile_ip": profile_ip, + "regex_type": regex_type, + "antigen_value": antigen_value, + "state": STATE_MATURE, + "state_name": STATE_INFO[STATE_MATURE]["label"], + "matched_regex_hash": None, + "matched_regex": None, + "matched_value": None, + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": None, + "last_evidence_id": None, + "last_transition_at": None, + "last_co_stimulation": None, + "last_effector_score": None, + "last_memory_score": None, + "context": {}, + "created_at": now, + "updated_at": now, + } + + def _transition_cell( + self, + cell: dict, + to_state: int, + reason: str, + evidence, + observation_id: int, + now: float, + match: RegexMatch | None = None, + scores: dict | None = None, + extra_updates: dict | None = None, + ) -> dict: + from_state = cell["state"] + updates = { + "state": to_state, + "state_name": STATE_INFO[to_state]["label"], + "last_observation_id": observation_id, + "last_evidence_id": evidence.id, + "last_transition_at": now, + } + if match: + updates.update( + { "matched_regex_hash": match.regex_hash, - }, + "matched_regex": match.regex, + "matched_value": match.value, + } + ) + if extra_updates: + updates.update(extra_updates) + + cell = self._update_cell(cell, now, **updates) + self.storage.insert_transition( + { + "cell_key": cell["cell_key"], + "profile_ip": cell["profile_ip"], + "regex_type": cell["regex_type"], + "antigen_value": cell["antigen_value"], + "evidence_id": evidence.id, + "observation_id": observation_id, + "from_state": from_state, + "to_state": to_state, + "reason": reason, + "matched_regex_hash": cell.get("matched_regex_hash"), + "matched_regex": cell.get("matched_regex"), + "matched_value": cell.get("matched_value"), + "scores": scores or {}, + "created_at": now, + } + ) + self._log_event( + action=reason, + state=to_state, + evidence=evidence, + cell=cell, + match=match, + metrics=scores, + verbosity=LOG_VERBOSITY_SUMMARY, + ) + return cell + + def _update_cell(self, cell: dict, now: float, **updates) -> dict: + cell.update(updates) + cell["updated_at"] = now + self.storage.upsert_cell(cell) + return cell + + @staticmethod + def _merge_cell_context_values(cell: dict, **updates) -> dict: + merged = dict(cell.get("context") or {}) + for key, value in updates.items(): + if value is CONTEXT_REMOVE: + merged.pop(key, None) + continue + merged[key] = value + return merged + + def _update_cell_context(self, cell: dict, now: float, **updates) -> dict: + return self._update_cell( + cell, + now, + context=self._merge_cell_context_values(cell, **updates), + ) + + def _remember_match_context( + self, + cell: dict, + now: float, + observation_id: int, + evidence_id: str, + match: RegexMatch, + ) -> dict: + return self._update_cell_context( + cell, + now, + recognition_observation_id=observation_id, + recognition_evidence_id=evidence_id, + matched_regex_created_at=match.created_at, + matched_regex_specificity=match.specificity, + ) + + def _clear_waiting_context(self, cell: dict, now: float) -> dict: + return self._update_cell_context( + cell, + now, + waiting_for=CONTEXT_REMOVE, + waiting_label=CONTEXT_REMOVE, + waiting_since=CONTEXT_REMOVE, + wait_deadline=CONTEXT_REMOVE, + wait_trigger_signal=CONTEXT_REMOVE, + wait_trigger_evidence_id=CONTEXT_REMOVE, + wait_trigger_observation_id=CONTEXT_REMOVE, + ) + + def _set_waiting_context( + self, + cell: dict, + now: float, + waiting_for: str, + evidence, + observation_id: int, + ) -> dict: + context = cell.get("context") or {} + waiting_since = context.get("waiting_since") + if context.get("waiting_for") != waiting_for or waiting_since is None: + waiting_since = ( + cell.get("last_transition_at") + or cell.get("created_at") + or now + ) + try: + waiting_since = float(waiting_since) + except (TypeError, ValueError): + waiting_since = float(now) + return self._update_cell_context( + cell, + now, + waiting_for=waiting_for, + waiting_label=WAITING_LABELS.get(waiting_for, waiting_for), + waiting_since=waiting_since, + wait_deadline=waiting_since + self.state_wait_timeout_seconds, + wait_trigger_signal=str(evidence.evidence_signal), + wait_trigger_evidence_id=evidence.id, + wait_trigger_observation_id=observation_id, + ) + + def _get_reference_observation_id( + self, cell: dict, fallback_observation_id: int + ) -> int: + context = cell.get("context") or {} + candidate_id = ( + context.get("recognition_observation_id") + or cell.get("last_observation_id") + or fallback_observation_id + ) + try: + return int(candidate_id) + except (TypeError, ValueError): + return int(fallback_observation_id) + + def _build_match_from_cell(self, cell: dict) -> RegexMatch | None: + regex_hash = str(cell.get("matched_regex_hash") or "").strip() + regex = str(cell.get("matched_regex") or "").strip() + regex_type = str(cell.get("regex_type") or "").strip() + value = str( + cell.get("matched_value") or cell.get("antigen_value") or "" + ).strip() + if not (regex_hash and regex and regex_type and value): + return None + + context = cell.get("context") or {} + created_at = context.get("matched_regex_created_at") or 0.0 + try: + created_at = float(created_at) + except (TypeError, ValueError): + created_at = 0.0 + + specificity = context.get("matched_regex_specificity") + try: + specificity = float(specificity) + except (TypeError, ValueError): + specificity = measure_regex_specificity(regex) + + return RegexMatch( + regex_type=regex_type, + value=value, + regex_hash=regex_hash, + regex=regex, + created_at=created_at, + specificity=specificity, + ) + + def _reevaluate_waiting_cells( + self, + evidence, + observation_id: int, + responsible_ip: str, + now: float, + ) -> int: + waiting_cells = self.storage.get_cells_for_profile_states( + responsible_ip, + [STATE_ANTIGEN_RECOGNIZED, STATE_ACTIVATED], + ) + reevaluated = 0 + for cell in waiting_cells: + match = self._build_match_from_cell(cell) + if not match: + self._log_event( + action="waiting_cell_missing_match", + state=cell["state"], + evidence=evidence, + cell=cell, + details=( + "cannot reevaluate waiting cell because the stored " + "regex match metadata is incomplete" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + continue + + candidate = AntigenCandidate( + regex_type=cell["regex_type"], + value=cell["antigen_value"], + ) + reference_observation_id = self._get_reference_observation_id( + cell, + observation_id, + ) + self._advance_cell_with_match( + cell=cell, + evidence=evidence, + observation_id=observation_id, + candidate=candidate, + match=match, + now=now, + responsible_ip=responsible_ip, + reference_observation_id=reference_observation_id, + ) + reevaluated += 1 + return reevaluated + + def _advance_cell_with_match( + self, + cell: dict, + evidence, + observation_id: int, + candidate: AntigenCandidate, + match: RegexMatch, + now: float, + responsible_ip: str, + reference_observation_id: int, + ) -> RegexMatch: + if ( + cell.get("last_observation_id") != observation_id + or cell.get("last_evidence_id") != evidence.id + ): + cell = self._update_cell( + cell, + now, + last_observation_id=observation_id, + last_evidence_id=evidence.id, + ) + + if cell["state"] == STATE_MEMORY: + cell = self._update_cell_context( + cell, + now, + reason="memory_retained", + observation_id=observation_id, + matched_regex_hash=match.regex_hash, ) self._log_event( action="memory_retained", @@ -435,7 +797,7 @@ def _process_candidate( co_stimulation = self._compute_co_stimulation( responsible_ip, - observation_id, + reference_observation_id, candidate, match, now, @@ -444,7 +806,11 @@ def _process_candidate( cell, now, last_co_stimulation=co_stimulation["value"], - context={"co_stimulation": co_stimulation}, + ) + cell = self._update_cell_context( + cell, + now, + co_stimulation=co_stimulation, ) if cell["state"] < STATE_ACTIVATED: @@ -473,6 +839,7 @@ def _process_candidate( match=match, scores=co_stimulation, ) + cell = self._clear_waiting_context(cell, now) elif ( cell["state"] == STATE_ANTIGEN_RECOGNIZED and self._state_wait_expired(cell, now) @@ -513,8 +880,16 @@ def _process_candidate( "anergic_until": now + self.anergy_ttl_seconds, }, ) + cell = self._clear_waiting_context(cell, now) return match else: + cell = self._set_waiting_context( + cell, + now, + WAITING_CO_STIMULATION, + evidence, + observation_id, + ) self._maybe_trace_co_stimulation( action="waiting_for_co_stimulation", evidence=evidence, @@ -540,8 +915,8 @@ def _process_candidate( match=match, details=( "score below threshold; keeping the cell in " - "antigen-recognized state until more corroborating " - "PAMPs arrive" + "antigen-recognized state and reevaluating on future " + "PAMP or DAMP evidence" ), metrics={ "score": co_stimulation["value"], @@ -567,7 +942,7 @@ def _process_candidate( context = self._compute_context_signals( responsible_ip, - observation_id, + reference_observation_id, candidate, match, now, @@ -577,7 +952,12 @@ def _process_candidate( now, last_effector_score=context["effector_score"], last_memory_score=context["memory_score"], - context={"co_stimulation": co_stimulation, "context": context}, + ) + cell = self._update_cell_context( + cell, + now, + co_stimulation=co_stimulation, + context=context, ) if context["effector"]: @@ -605,6 +985,7 @@ def _process_candidate( match=match, scores=context, ) + cell = self._clear_waiting_context(cell, now) self._apply_effector( cell, evidence, @@ -640,6 +1021,7 @@ def _process_candidate( match=match, scores=context, ) + cell = self._clear_waiting_context(cell, now) self._store_memory(cell, match, context, now) self._log_event( action="memory_stored", @@ -674,7 +1056,7 @@ def _process_candidate( from_state=cell["state"], to_state=STATE_MATURE, ) - self._transition_cell( + cell = self._transition_cell( cell=cell, to_state=STATE_MATURE, reason="context_timeout", @@ -688,8 +1070,16 @@ def _process_candidate( "wait_limit": self.state_wait_timeout_seconds, }, ) + cell = self._clear_waiting_context(cell, now) return match + cell = self._set_waiting_context( + cell, + now, + WAITING_CONTEXT, + evidence, + observation_id, + ) self._maybe_trace_context( action="waiting_for_context", evidence=evidence, @@ -715,7 +1105,8 @@ def _process_candidate( match=match, details=( "context is not strong enough yet for effector or memory; " - "keeping the current state and reevaluating on future PAMPs" + "keeping the current state and reevaluating on future PAMP " + "or DAMP evidence" ), metrics={ "effector_score": context["effector_score"], @@ -737,104 +1128,6 @@ def _process_candidate( ) return match - def _get_or_create_cell( - self, profile_ip: str, regex_type: str, antigen_value: str, now: float - ) -> dict: - cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) - cell = self.storage.get_cell(cell_key) - if cell: - return cell - - return { - "cell_key": cell_key, - "profile_ip": profile_ip, - "regex_type": regex_type, - "antigen_value": antigen_value, - "state": STATE_MATURE, - "state_name": STATE_INFO[STATE_MATURE]["label"], - "matched_regex_hash": None, - "matched_regex": None, - "matched_value": None, - "anergic_until": None, - "effector_cooldown_until": None, - "last_observation_id": None, - "last_evidence_id": None, - "last_transition_at": None, - "last_co_stimulation": None, - "last_effector_score": None, - "last_memory_score": None, - "context": {}, - "created_at": now, - "updated_at": now, - } - - def _transition_cell( - self, - cell: dict, - to_state: int, - reason: str, - evidence, - observation_id: int, - now: float, - match: RegexMatch | None = None, - scores: dict | None = None, - extra_updates: dict | None = None, - ) -> dict: - from_state = cell["state"] - updates = { - "state": to_state, - "state_name": STATE_INFO[to_state]["label"], - "last_observation_id": observation_id, - "last_evidence_id": evidence.id, - "last_transition_at": now, - } - if match: - updates.update( - { - "matched_regex_hash": match.regex_hash, - "matched_regex": match.regex, - "matched_value": match.value, - } - ) - if extra_updates: - updates.update(extra_updates) - - cell = self._update_cell(cell, now, **updates) - self.storage.insert_transition( - { - "cell_key": cell["cell_key"], - "profile_ip": cell["profile_ip"], - "regex_type": cell["regex_type"], - "antigen_value": cell["antigen_value"], - "evidence_id": evidence.id, - "observation_id": observation_id, - "from_state": from_state, - "to_state": to_state, - "reason": reason, - "matched_regex_hash": cell.get("matched_regex_hash"), - "matched_regex": cell.get("matched_regex"), - "matched_value": cell.get("matched_value"), - "scores": scores or {}, - "created_at": now, - } - ) - self._log_event( - action=reason, - state=to_state, - evidence=evidence, - cell=cell, - match=match, - metrics=scores, - verbosity=LOG_VERBOSITY_SUMMARY, - ) - return cell - - def _update_cell(self, cell: dict, now: float, **updates) -> dict: - cell.update(updates) - cell["updated_at"] = now - self.storage.upsert_cell(cell) - return cell - def _compute_co_stimulation( self, profile_ip: str, @@ -877,6 +1170,8 @@ def _compute_co_stimulation( return { "value": value, "confidence": confidence, + "confidence_observation_id": current_observation.get("id"), + "confidence_evidence_id": current_observation.get("evidence_id"), "related_pamp_count": related_pamp_count, "related_pamp_score": related_pamp_score, "profile_danger_score": profile_danger_score, @@ -1059,7 +1354,13 @@ def _maybe_trace_co_stimulation( self.co_stimulation_weights["confidence"] * co_stimulation["confidence"] ), - "evidence_id": evidence.id, + "evidence_id": co_stimulation.get( + "confidence_evidence_id" + ) + or evidence.id, + "observation_id": co_stimulation.get( + "confidence_observation_id" + ), }, "related_pamps": { "count": co_stimulation["related_pamp_count"], @@ -1416,7 +1717,12 @@ def _apply_effector( cell, now, effector_cooldown_until=next_cooldown, - context={"context": context, "effector_payload": blocking_data}, + ) + self._update_cell_context( + cell, + now, + context=context, + effector_payload=blocking_data, ) if self._blocking_modules_available(): @@ -1850,8 +2156,21 @@ def _resolve_trace_file_path(self, raw_path: str) -> str: safe_parts = ["t_cell_trace.jsonl"] return os.path.join(self.output_dir, *safe_parts) - def _colorize_state(self, state: int) -> str: + @staticmethod + def _get_waiting_label(cell: dict | None) -> str: + context = (cell or {}).get("context") or {} + waiting_for = context.get("waiting_for") + return WAITING_LABELS.get(waiting_for, "") + + def _format_state_label(self, state: int, cell: dict | None = None) -> str: label = STATE_INFO[state]["label"] + waiting_label = self._get_waiting_label(cell) + if waiting_label: + return f"{label} ({waiting_label})" + return label + + def _colorize_state(self, state: int, cell: dict | None = None) -> str: + label = self._format_state_label(state, cell) if not self.log_colors: return label return f"{STATE_INFO[state]['color']}{label}{COLOR_RESET}" @@ -1874,7 +2193,7 @@ def _log_event( f"action={action}", ] if state is not None: - parts.append(f"state={self._colorize_state(state)}") + parts.append(f"state={self._colorize_state(state, cell=cell)}") if evidence: parts.append(f"evidence={evidence.evidence_type.name}") parts.append(f"eid={evidence.id}") @@ -1888,6 +2207,9 @@ def _log_event( parts.append(f"target={target_ip}") if cell: parts.append(f"cell={cell['cell_key']}") + waiting_label = self._get_waiting_label(cell) + if waiting_label: + parts.append(f"waiting={waiting_label}") if match: parts.append(f"regex={match.regex_hash}") parts.append(f"value={match.value}") From 1a083588a7da833a4ac1485811b3e5a994cee66a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:57 +0000 Subject: [PATCH 0170/1100] feat: add method to retrieve cells for specific profile states in TCellSQLiteDB --- .../core/database/sqlite_db/t_cell_db.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/slips_files/core/database/sqlite_db/t_cell_db.py b/slips_files/core/database/sqlite_db/t_cell_db.py index bfd40367e1..8441bf8683 100644 --- a/slips_files/core/database/sqlite_db/t_cell_db.py +++ b/slips_files/core/database/sqlite_db/t_cell_db.py @@ -285,6 +285,27 @@ def get_all_cells(self) -> list[dict]: rows = self.select("cells", order_by="updated_at DESC") or [] return [self._row_to_cell(row) for row in rows] + def get_cells_for_profile_states( + self, profile_ip: str, states: list[int] | tuple[int, ...] + ) -> list[dict]: + normalized_states = [ + int(state) for state in (states or []) if state is not None + ] + if not normalized_states: + return [] + + placeholders = ", ".join("?" for _ in normalized_states) + rows = self.select( + "cells", + condition=( + f"profile_ip = ? AND state IN ({placeholders})" + ), + params=(profile_ip, *normalized_states), + order_by="updated_at DESC, created_at DESC", + ) + rows = rows or [] + return [self._row_to_cell(row) for row in rows] + def upsert_cell(self, record: dict): self.execute( "INSERT OR REPLACE INTO cells (" @@ -568,6 +589,11 @@ def get_cell(self, cell_key: str) -> dict | None: def get_all_cells(self) -> list[dict]: return self.db.get_all_cells() + def get_cells_for_profile_states( + self, profile_ip: str, states: list[int] | tuple[int, ...] + ) -> list[dict]: + return self.db.get_cells_for_profile_states(profile_ip, states) + def upsert_cell(self, record: dict): self.db.upsert_cell(record) From f35afa15293e416bf35ea68968a0f01720c8bfde Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:13 +0000 Subject: [PATCH 0171/1100] feat: add upsert functionality for activated cell state and update report assertions --- .../modules/t_cell/test_analyze_t_cell.py | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index 454a5fb29a..ba4812dd59 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -151,6 +151,34 @@ def test_build_report_payload_and_html(tmp_path): "updated_at": 2000.3, } ) + storage.upsert_cell( + { + "cell_key": "192.168.1.121|tls_sni|arpanet-network.com", + "profile_ip": "192.168.1.121", + "regex_type": "tls_sni", + "antigen_value": "arpanet-network.com", + "state": 3, + "state_name": "3 - activated", + "matched_regex_hash": "regex-hash-2", + "matched_regex": r"arpanet-network\.com$", + "matched_value": "arpanet-network.com", + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": pamp_observation_id, + "last_evidence_id": "pamp-1", + "last_transition_at": 2000.4, + "last_co_stimulation": 1.0, + "last_effector_score": 0.70, + "last_memory_score": 0.40, + "context": { + "waiting_for": "context", + "waiting_since": 2000.4, + "wait_deadline": 2060.4, + }, + "created_at": 2000.4, + "updated_at": 2000.4, + } + ) storage.insert_transition( { "cell_key": cell_key, @@ -300,10 +328,15 @@ def test_build_report_payload_and_html(tmp_path): assert payload["totals"]["signals"] == {"DAMP": 1, "PAMP": 1} assert payload["totals"]["transitions"] == 3 assert payload["totals"]["memories"] == 1 - assert payload["cell_states"] == {"5 - memory": 1} + assert payload["cell_states"]["5 - memory"] == 1 + assert payload["cell_states"]["3 - activated"] == 1 assert payload["sources"]["trace_enabled"] is True assert payload["trace"]["total_rows"] == 2 assert payload["recent_observations"][0]["category"] == "PAMP with regex match" + assert any( + row["waiting_label"] == "waiting for context" + for row in payload["recent_cells"] + ) assert any( row["category"] == "DAMP with extracted antigens" for row in payload["recent_observations"] @@ -319,10 +352,11 @@ def test_build_report_payload_and_html(tmp_path): assert "Decision Trace" in html assert "T Cell State Machine" in html assert "regex match" in html - assert "current cells: 1" in html + assert "current cells:" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html assert "data-sortable-table='recent-transitions'" in html + assert "data-sortable-table='recent-cells'" in html assert "data-default-sort-column='4'" in html assert "Default order groups rows by T cell" in html assert "Click a column header to sort." in html @@ -332,5 +366,7 @@ def test_build_report_payload_and_html(tmp_path): assert "bad.example.com" in html assert "DAMP with extracted antigens" in html assert "PAMP with regex match" in html + assert "waiting for context" in html + assert "3 - activated (waiting for context)" not in html storage.close() From 4aa5227a35a1d37acc0c7e515369c9ab3272acfd Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:22 +0000 Subject: [PATCH 0172/1100] feat: enhance DAMP evidence handling and add tests for waiting cell re-evaluation --- tests/unit/modules/t_cell/test_t_cell.py | 130 ++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/tests/unit/modules/t_cell/test_t_cell.py b/tests/unit/modules/t_cell/test_t_cell.py index 9f39d534c7..6e14d84668 100644 --- a/tests/unit/modules/t_cell/test_t_cell.py +++ b/tests/unit/modules/t_cell/test_t_cell.py @@ -228,7 +228,7 @@ def test_extract_antigen_candidates_from_entities_and_altflows(tmp_path): assert ("certificate_cn", "cn.bad.example.com") in extracted -def test_t_cell_ignores_damp_evidence(tmp_path): +def test_t_cell_stores_damp_evidence_and_checks_waiting_cells(tmp_path): t_cell, storage = _prepare_t_cell(tmp_path) evidence = _build_evidence("damp-1", signal=EvidenceSignal.DAMP) @@ -242,7 +242,8 @@ def test_t_cell_ignores_damp_evidence(tmp_path): t_cell.db.publish.assert_not_called() with open(t_cell.log_file_path, encoding="utf-8") as log_file: log_contents = log_file.read() - assert "ignored_non_pamp" in log_contents + assert "damp_reverification" in log_contents + assert "reevaluated_cells=0" in log_contents assert "signal=DAMP" in log_contents @@ -797,7 +798,7 @@ def test_t_cell_summary_log_hides_waiting_for_co_stimulation(tmp_path): def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): - t_cell, _ = _prepare_t_cell(tmp_path, log_verbosity=2) + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) evidence = _build_evidence("pending-2", uids=["dns-1"]) t_cell.db.get_altflow_from_uid.return_value = { "type_": "dns", @@ -813,12 +814,135 @@ def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): with open(t_cell.log_file_path, encoding="utf-8") as log_file: log_contents = log_file.read() + cell = storage.get_all_cells()[0] + assert cell["context"]["waiting_for"] == "co_stimulation" assert "waiting_for_co_stimulation" in log_contents + assert "waiting=waiting for co-stimulation" in log_contents assert "score=" in log_contents assert "threshold=" in log_contents assert "related_pamps=" in log_contents +def test_t_cell_damp_reverifies_waiting_co_stimulation_cells(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + fixed_now = 14_500.0 + profile_ip = "10.0.0.80" + evidence_pamp = _build_evidence( + "damp-reverify-costim-pamp", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + evidence_damp = _build_evidence( + "damp-reverify-costim-damp", + signal=EvidenceSignal.DAMP, + profile_ip=profile_ip, + threat_level=ThreatLevel.CRITICAL, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-reverify-costim-regex" + ) + _insert_observation( + storage=storage, + evidence_id="seed-damp-1", + profile_ip=profile_ip, + antigens=[], + observed_at=fixed_now - 20, + confidence=1.0, + threat_level_value=1.0, + threat_level="critical", + evidence_signal="DAMP", + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_pamp)) + + first_cell = storage.get_all_cells()[0] + assert first_cell["state"] == STATE_ANTIGEN_RECOGNIZED + assert first_cell["context"]["waiting_for"] == "co_stimulation" + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_damp)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_ACTIVATED + assert cell["context"]["waiting_for"] == "context" + assert any( + transition["reason"] == "co_stimulation_threshold_met" + and transition["evidence_id"] == evidence_damp.id + for transition in transitions + ) + + +def test_t_cell_damp_reverifies_waiting_context_cells(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + fixed_now = 14_800.0 + profile_ip = "10.0.0.81" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence_pamp = _build_evidence( + "damp-reverify-context-pamp", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + evidence_damp = _build_evidence( + "damp-reverify-context-damp", + signal=EvidenceSignal.DAMP, + profile_ip=profile_ip, + threat_level=ThreatLevel.CRITICAL, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-reverify-context-regex" + ) + t_cell.db.get_pid_of.side_effect = ( + lambda name: 123 if name == "Blocking" else None + ) + _seed_recent_related_observations( + storage, + profile_ip, + antigen, + fixed_now, + count=5, + confidence=1.0, + threat_level_value=0.1, + age_seconds=120, + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_pamp)) + + first_cell = storage.get_all_cells()[0] + assert first_cell["state"] == STATE_ACTIVATED + assert first_cell["context"]["waiting_for"] == "context" + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_damp)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_EFFECTOR + assert "waiting_for" not in cell["context"] + assert any( + transition["reason"] == "context_effector" + and transition["evidence_id"] == evidence_damp.id + for transition in transitions + ) + assert t_cell.db.publish.call_count == 1 + + def test_t_cell_log_file_contains_color_codes(tmp_path): t_cell, _ = _prepare_t_cell(tmp_path) evidence = _build_evidence("log-1") From 7094f63e4c1a2819dce5bc162066536d8cf1d9fa Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:30 +0000 Subject: [PATCH 0173/1100] feat: add T Cell report overview image to documentation --- docs/images/t_cell/t_cell_report_overview.png | Bin 0 -> 617207 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/t_cell/t_cell_report_overview.png diff --git a/docs/images/t_cell/t_cell_report_overview.png b/docs/images/t_cell/t_cell_report_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..82fcbf4649af910b57f2c9b1f1feaa56d0cae31b GIT binary patch literal 617207 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

    S_UTAO{Kq(wuHbDYV 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

    """ From ffd8c4de904dc22f95057da7c49ea055a525c315 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:36 +0200 Subject: [PATCH 0177/1100] feat: add script to analyze alert creation delay from Slips alerts exports --- scripts/analyze_alert_creation_delay.py | 632 ++++++++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100755 scripts/analyze_alert_creation_delay.py diff --git a/scripts/analyze_alert_creation_delay.py b/scripts/analyze_alert_creation_delay.py new file mode 100755 index 0000000000..40f0038db5 --- /dev/null +++ b/scripts/analyze_alert_creation_delay.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Analyze alert creation delay from Slips alerts exports. + +This script measures the delay between each alert's CreateTime and StartTime, +then summarizes the distribution and how it evolves over time. It supports the +newline-delimited JSON format used by alerts.json as well as plain JSON arrays. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import sys +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path + + +DEFAULT_RESOLUTIONS = ("day", "hour", "minute") +VALID_RESOLUTIONS = set(DEFAULT_RESOLUTIONS) +DELAY_BANDS = ( + ("negative", None, 0.0), + ("0s-1s", 0.0, 1.0), + ("1s-10s", 1.0, 10.0), + ("10s-60s", 10.0, 60.0), + ("1m-5m", 60.0, 300.0), + ("5m-1h", 300.0, 3600.0), + ("1h-1d", 3600.0, 86400.0), + (">=1d", 86400.0, None), +) + + +@dataclass(frozen=True) +class AlertDelayRecord: + record_number: int + alert_id: str + severity: str + create_time: str + start_time: str + delay_seconds: float + description: str + + +@dataclass(frozen=True) +class SummaryStats: + count: int + min_seconds: float + mean_seconds: float + p50_seconds: float + p90_seconds: float + p95_seconds: float + p99_seconds: float + max_seconds: float + + +@dataclass(frozen=True) +class BucketSummary: + bucket_start: str + count: int + min_seconds: float + mean_seconds: float + p50_seconds: float + p95_seconds: float + p99_seconds: float + max_seconds: float + + +def parse_args() -> argparse.Namespace: + class HelpFormatter( + argparse.ArgumentDefaultsHelpFormatter, + argparse.RawDescriptionHelpFormatter, + ): + pass + + parser = argparse.ArgumentParser( + description=( + "Analyze alert creation delay in Slips alerts exports.\n\n" + "The script reads alerts.json, computes the per-alert delay as\n" + "CreateTime - StartTime, then summarizes the overall distribution\n" + "and how that delay evolves over time by day, hour, and minute." + ), + epilog=( + "Input format:\n" + " alerts.json can be newline-delimited JSON (one alert per line)\n" + " or a regular JSON array of alert objects.\n\n" + "Outputs:\n" + " The terminal output shows overall statistics, delay bands,\n" + " the alerts with the largest delays, and trend tables.\n" + " If --output-dir is given, the script also writes CSV files for\n" + " each selected time resolution plus a summary.json file.\n\n" + "Example:\n" + " python3 scripts/analyze_alert_creation_delay.py \\\n" + " output/test-tcell-8/alerts.json \\\n" + " --output-dir output/test-tcell-8/alert_creation_delay_report" + ), + formatter_class=HelpFormatter, + ) + parser.add_argument( + "alerts_path", + help="Path to alerts.json (JSONL or JSON array).", + ) + parser.add_argument( + "--bucket-time", + choices=("create", "start"), + default="create", + help=( + "Which timestamp to use for trend buckets. Default: create " + "(group by CreateTime)." + ), + ) + parser.add_argument( + "--resolution", + action="append", + choices=sorted(VALID_RESOLUTIONS), + help=( + "Trend resolution to emit. Repeat to select a subset. " + "Default: day, hour, minute." + ), + ) + parser.add_argument( + "--output-dir", + default="", + help=( + "Optional directory where CSV trend files, top-delays CSV, and " + "summary.json will be written." + ), + ) + parser.add_argument( + "--print-limit", + type=int, + default=120, + help=( + "Print all buckets when a resolution has at most this many buckets. " + "Default: 120." + ), + ) + parser.add_argument( + "--top-buckets", + type=int, + default=10, + help=( + "When a resolution has many buckets, print this many worst buckets " + "and this many most recent buckets. Default: 10." + ), + ) + parser.add_argument( + "--top-alerts", + type=int, + default=10, + help="Show this many alerts with the largest delays. Default: 10.", + ) + parser.add_argument( + "--description-width", + type=int, + default=110, + help="Maximum description width in the top-alerts section. Default: 110.", + ) + return parser.parse_args() + + +def detect_input_format(path: Path) -> str: + with path.open(encoding="utf-8") as handle: + while True: + char = handle.read(1) + if not char: + raise ValueError(f"{path} is empty") + if char.isspace(): + continue + return "json-array" if char == "[" else "jsonl" + + +def iter_alert_records(path: Path): + input_format = detect_input_format(path) + if input_format == "json-array": + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, list): + raise ValueError(f"{path} is a JSON array file but did not contain a list") + for index, alert in enumerate(payload, start=1): + if not isinstance(alert, dict): + raise ValueError(f"Record {index} is not a JSON object") + yield input_format, index, alert + return + + with path.open(encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + stripped = line.strip() + if not stripped: + continue + try: + alert = json.loads(stripped) + except json.JSONDecodeError as exc: + raise ValueError( + f"Invalid JSON on line {line_number}: {exc.msg}" + ) from exc + if not isinstance(alert, dict): + raise ValueError(f"Line {line_number} is not a JSON object") + yield input_format, line_number, alert + + +def parse_timestamp(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +def truncate_datetime(value: datetime, resolution: str) -> datetime: + if resolution == "day": + return value.replace(hour=0, minute=0, second=0, microsecond=0) + if resolution == "hour": + return value.replace(minute=0, second=0, microsecond=0) + if resolution == "minute": + return value.replace(second=0, microsecond=0) + raise ValueError(f"Unsupported resolution: {resolution}") + + +def percentile(sorted_values: list[float], fraction: float) -> float: + if not sorted_values: + raise ValueError("percentile() requires at least one value") + if len(sorted_values) == 1: + return sorted_values[0] + position = (len(sorted_values) - 1) * fraction + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return sorted_values[lower] + lower_value = sorted_values[lower] + upper_value = sorted_values[upper] + return lower_value + (upper_value - lower_value) * (position - lower) + + +def build_summary(values: list[float]) -> SummaryStats: + if not values: + raise ValueError("No values available to summarize") + ordered = sorted(values) + return SummaryStats( + count=len(ordered), + min_seconds=ordered[0], + mean_seconds=sum(ordered) / len(ordered), + p50_seconds=percentile(ordered, 0.50), + p90_seconds=percentile(ordered, 0.90), + p95_seconds=percentile(ordered, 0.95), + p99_seconds=percentile(ordered, 0.99), + max_seconds=ordered[-1], + ) + + +def build_bucket_summaries( + bucket_values: dict[datetime, list[float]] +) -> list[BucketSummary]: + summaries: list[BucketSummary] = [] + for bucket_start, values in sorted(bucket_values.items()): + ordered = sorted(values) + summaries.append( + BucketSummary( + bucket_start=bucket_start.isoformat(), + count=len(ordered), + min_seconds=ordered[0], + mean_seconds=sum(ordered) / len(ordered), + p50_seconds=percentile(ordered, 0.50), + p95_seconds=percentile(ordered, 0.95), + p99_seconds=percentile(ordered, 0.99), + max_seconds=ordered[-1], + ) + ) + return summaries + + +def delay_band_label(delay_seconds: float) -> str: + for label, lower, upper in DELAY_BANDS: + if lower is None and delay_seconds < upper: + return label + if upper is None and delay_seconds >= lower: + return label + if lower is not None and upper is not None and lower <= delay_seconds < upper: + return label + return "unclassified" + + +def ellipsize(text: str, width: int) -> str: + if width <= 3 or len(text) <= width: + return text + return text[: width - 3] + "..." + + +def print_summary_stats(summary: SummaryStats): + print("Overall delay statistics (CreateTime - StartTime, in seconds)") + print(f" alerts: {summary.count:,}") + print(f" min_s: {summary.min_seconds:.6f}") + print(f" mean_s: {summary.mean_seconds:.6f}") + print(f" p50_s: {summary.p50_seconds:.6f}") + print(f" p90_s: {summary.p90_seconds:.6f}") + print(f" p95_s: {summary.p95_seconds:.6f}") + print(f" p99_s: {summary.p99_seconds:.6f}") + print(f" max_s: {summary.max_seconds:.6f}") + + +def print_delay_bands(band_counts: dict[str, int], total: int): + print("\nDelay bands") + for label, _, _ in DELAY_BANDS: + count = band_counts.get(label, 0) + percentage = (count / total * 100) if total else 0.0 + print(f" {label:>8}: {count:>9,} ({percentage:6.2f}%)") + + +def print_top_alerts(top_alerts: list[AlertDelayRecord], description_width: int): + if not top_alerts: + return + print("\nLargest per-alert delays") + for rank, item in enumerate(top_alerts, start=1): + description = ellipsize(item.description.replace("\n", " "), description_width) + print( + f" {rank:>2}. delay_s={item.delay_seconds:>12.6f} " + f"record={item.record_number:<8} severity={item.severity or '-':<6} " + f"id={item.alert_id or '-'}" + ) + print( + f" start={item.start_time} create={item.create_time} " + f"description={description}" + ) + + +def print_bucket_table(rows: list[BucketSummary]): + if not rows: + print(" no buckets") + return + header = ( + f"{'bucket_start':<25} {'count':>8} {'min_s':>12} {'mean_s':>12} " + f"{'p50_s':>12} {'p95_s':>12} {'p99_s':>12} {'max_s':>12}" + ) + print(header) + print("-" * len(header)) + for row in rows: + print( + f"{row.bucket_start:<25} {row.count:>8,} " + f"{row.min_seconds:>12.3f} {row.mean_seconds:>12.3f} " + f"{row.p50_seconds:>12.3f} {row.p95_seconds:>12.3f} " + f"{row.p99_seconds:>12.3f} {row.max_seconds:>12.3f}" + ) + + +def print_resolution_summary( + resolution: str, + rows: list[BucketSummary], + print_limit: int, + top_buckets: int, + csv_path: Path | None, +): + print(f"\nBy {resolution}") + if not rows: + print(" no data") + return + + first_row = rows[0] + last_row = rows[-1] + print( + f" buckets: {len(rows):,}; first={first_row.bucket_start}; " + f"last={last_row.bucket_start}" + ) + print( + f" first mean/p50/p95: {first_row.mean_seconds:.3f} / " + f"{first_row.p50_seconds:.3f} / {first_row.p95_seconds:.3f} seconds" + ) + print( + f" last mean/p50/p95: {last_row.mean_seconds:.3f} / " + f"{last_row.p50_seconds:.3f} / {last_row.p95_seconds:.3f} seconds" + ) + if csv_path is not None: + print(f" csv: {csv_path}") + + if len(rows) <= print_limit: + print_bucket_table(rows) + return + + worst_rows = sorted( + rows, + key=lambda row: (row.p95_seconds, row.max_seconds, row.mean_seconds), + reverse=True, + )[:top_buckets] + recent_rows = rows[-top_buckets:] + + print(f" {len(rows):,} buckets exceed --print-limit={print_limit}.") + print(f" Worst {len(worst_rows)} buckets by p95_s") + print_bucket_table(sorted(worst_rows, key=lambda row: row.bucket_start)) + print(f"\n Most recent {len(recent_rows)} buckets") + print_bucket_table(recent_rows) + + +def write_bucket_csv(path: Path, rows: list[BucketSummary]): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "bucket_start", + "count", + "min_s", + "mean_s", + "p50_s", + "p95_s", + "p99_s", + "max_s", + ] + ) + for row in rows: + writer.writerow( + [ + row.bucket_start, + row.count, + f"{row.min_seconds:.6f}", + f"{row.mean_seconds:.6f}", + f"{row.p50_seconds:.6f}", + f"{row.p95_seconds:.6f}", + f"{row.p99_seconds:.6f}", + f"{row.max_seconds:.6f}", + ] + ) + + +def write_top_alerts_csv(path: Path, rows: list[AlertDelayRecord]): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "record_number", + "alert_id", + "severity", + "create_time", + "start_time", + "delay_s", + "description", + ] + ) + for row in rows: + writer.writerow( + [ + row.record_number, + row.alert_id, + row.severity, + row.create_time, + row.start_time, + f"{row.delay_seconds:.6f}", + row.description, + ] + ) + + +def ensure_output_dir(output_dir: str) -> Path | None: + if not output_dir: + return None + path = Path(output_dir).expanduser().resolve() + path.mkdir(parents=True, exist_ok=True) + return path + + +def main() -> int: + args = parse_args() + alerts_path = Path(args.alerts_path).expanduser().resolve() + if not alerts_path.exists(): + print(f"alerts file not found: {alerts_path}", file=sys.stderr) + return 1 + + resolutions = tuple(args.resolution or DEFAULT_RESOLUTIONS) + output_dir = ensure_output_dir(args.output_dir) + + overall_delays: list[float] = [] + bucket_values = { + resolution: defaultdict(list) for resolution in resolutions + } + band_counts: dict[str, int] = defaultdict(int) + top_delay_records: list[AlertDelayRecord] = [] + skipped_missing_timestamps = 0 + skipped_invalid_timestamps = 0 + negative_count = 0 + zero_count = 0 + trend_min: datetime | None = None + trend_max: datetime | None = None + input_format: str | None = None + + for current_format, record_number, alert in iter_alert_records(alerts_path): + input_format = current_format + create_time_raw = alert.get("CreateTime") + start_time_raw = alert.get("StartTime") + if not create_time_raw or not start_time_raw: + skipped_missing_timestamps += 1 + continue + + try: + create_time = parse_timestamp(create_time_raw) + start_time = parse_timestamp(start_time_raw) + except ValueError: + skipped_invalid_timestamps += 1 + continue + + delay_seconds = (create_time - start_time).total_seconds() + overall_delays.append(delay_seconds) + band_counts[delay_band_label(delay_seconds)] += 1 + if delay_seconds < 0: + negative_count += 1 + elif delay_seconds == 0: + zero_count += 1 + + top_delay_records.append( + AlertDelayRecord( + record_number=record_number, + alert_id=str(alert.get("ID") or ""), + severity=str(alert.get("Severity") or ""), + create_time=create_time_raw, + start_time=start_time_raw, + delay_seconds=delay_seconds, + description=str(alert.get("Description") or ""), + ) + ) + + trend_time = create_time if args.bucket_time == "create" else start_time + if trend_min is None or trend_time < trend_min: + trend_min = trend_time + if trend_max is None or trend_time > trend_max: + trend_max = trend_time + for resolution in resolutions: + bucket_values[resolution][ + truncate_datetime(trend_time, resolution) + ].append(delay_seconds) + + if not overall_delays: + print( + ( + "No alerts with valid CreateTime and StartTime were found in " + f"{alerts_path}" + ), + file=sys.stderr, + ) + return 1 + + overall_summary = build_summary(overall_delays) + top_delay_records = sorted( + top_delay_records, + key=lambda item: item.delay_seconds, + reverse=True, + )[: args.top_alerts] + bucket_summaries = { + resolution: build_bucket_summaries(bucket_values[resolution]) + for resolution in resolutions + } + + csv_paths: dict[str, str] = {} + if output_dir is not None: + for resolution in resolutions: + csv_path = output_dir / f"alert_creation_delay_by_{resolution}.csv" + write_bucket_csv(csv_path, bucket_summaries[resolution]) + csv_paths[resolution] = str(csv_path) + + top_alerts_csv = output_dir / "alert_creation_delay_top_alerts.csv" + write_top_alerts_csv(top_alerts_csv, top_delay_records) + csv_paths["top_alerts"] = str(top_alerts_csv) + + summary_json = output_dir / "summary.json" + summary_payload = { + "alerts_path": str(alerts_path), + "input_format": input_format, + "bucket_time": args.bucket_time, + "resolutions": list(resolutions), + "processed_alerts": overall_summary.count, + "skipped_missing_timestamps": skipped_missing_timestamps, + "skipped_invalid_timestamps": skipped_invalid_timestamps, + "negative_delays": negative_count, + "zero_delays": zero_count, + "trend_start": trend_min.isoformat() if trend_min else None, + "trend_end": trend_max.isoformat() if trend_max else None, + "overall_delay_seconds": asdict(overall_summary), + "delay_bands": [ + { + "label": label, + "count": band_counts.get(label, 0), + "percentage": ( + band_counts.get(label, 0) / overall_summary.count * 100 + ), + } + for label, _, _ in DELAY_BANDS + ], + "top_delays": [asdict(item) for item in top_delay_records], + "csv_outputs": csv_paths, + "bucket_counts": { + resolution: len(bucket_summaries[resolution]) + for resolution in resolutions + }, + } + with summary_json.open("w", encoding="utf-8") as handle: + json.dump(summary_payload, handle, indent=2) + handle.write("\n") + csv_paths["summary_json"] = str(summary_json) + + print(f"Input: {alerts_path}") + print(f"Input format: {input_format}") + print(f"Trend bucket timestamp: {args.bucket_time} time") + print( + f"Valid alerts: {overall_summary.count:,}; skipped missing timestamps: " + f"{skipped_missing_timestamps:,}; skipped invalid timestamps: " + f"{skipped_invalid_timestamps:,}" + ) + if trend_min is not None and trend_max is not None: + print(f"Trend range: {trend_min.isoformat()} -> {trend_max.isoformat()}") + print( + f"Negative delays: {negative_count:,}; zero delays: {zero_count:,}" + ) + print_summary_stats(overall_summary) + print_delay_bands(band_counts, overall_summary.count) + print_top_alerts(top_delay_records, args.description_width) + + for resolution in resolutions: + csv_path = Path(csv_paths[resolution]) if resolution in csv_paths else None + print_resolution_summary( + resolution=resolution, + rows=bucket_summaries[resolution], + print_limit=args.print_limit, + top_buckets=args.top_buckets, + csv_path=csv_path, + ) + + if output_dir is not None: + print(f"\nArtifacts written to: {output_dir}") + print(f"Summary JSON: {csv_paths['summary_json']}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4ab1ea86232260fa519955ba934cde4185a53950 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:45 +0200 Subject: [PATCH 0178/1100] feat: add regex auditing and pruning script for benign threshold management --- scripts/regex_prune_benign_threshold.py | 649 ++++++++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100755 scripts/regex_prune_benign_threshold.py diff --git a/scripts/regex_prune_benign_threshold.py b/scripts/regex_prune_benign_threshold.py new file mode 100755 index 0000000000..fe14cec516 --- /dev/null +++ b/scripts/regex_prune_benign_threshold.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Audit and optionally prune accepted regexes that exceed the benign threshold. + +This is meant for persistent regex stores where the benign corpus may have +grown over time. A regex accepted earlier can later become too strong against +the current benign corpus even though it passed at generation time. +""" + +from __future__ import annotations + +import argparse +import json +import re +import signal +import shutil +import sqlite3 +import sys +import time +import warnings +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.core.database.sqlite_db.regex_generator_db import REGEX_TYPES +from modules.regex_generator.match_strength import ( + compute_match_strength, + measure_regex_specificity, +) + + +@dataclass +class RegexAuditResult: + id: int + regex_type: str + regex: str + regex_hash: str + created_at: float + strongest_benign_score: float + strongest_benign_value: str + + +class _NullTimeout: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, exc_tb): + return False + + +class _SignalTimeout: + def __init__(self, timeout_seconds: float): + self.timeout_seconds = timeout_seconds + self._previous_handler = None + + def __enter__(self): + self._previous_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, self._handle_timeout) + signal.setitimer(signal.ITIMER_REAL, self.timeout_seconds) + return None + + def __exit__(self, exc_type, exc, exc_tb): + signal.setitimer(signal.ITIMER_REAL, 0) + if self._previous_handler is not None: + signal.signal(signal.SIGALRM, self._previous_handler) + return False + + @staticmethod + def _handle_timeout(signum, frame): + raise TimeoutError("regex benign scan timed out") + + +def timeout_context(timeout_seconds: float): + if timeout_seconds <= 0: + return _NullTimeout() + return _SignalTimeout(timeout_seconds) + + +class AuditProgressTracker: + BAR_WIDTH = 24 + + def __init__(self, total_regexes: int, totals_by_type: dict[str, int]): + self.total_regexes = max(1, total_regexes) + self.totals_by_type = dict(totals_by_type) + self.done_regexes = 0 + self.done_by_type = {regex_type: 0 for regex_type in totals_by_type} + self.current_type = "-" + self.comparisons_done = 0 + self.flagged_done = 0 + self.timed_out_done = 0 + self.started_at = time.monotonic() + self.last_render_at = 0.0 + self.enabled = sys.stderr.isatty() + + def start(self): + if not self.enabled: + return + print( + ( + "Auditing accepted regexes against the current benign corpus " + f"({self.total_regexes} regexes)" + ), + file=sys.stderr, + flush=True, + ) + self._render(force=True) + + def advance( + self, + regex_type: str, + comparisons: int, + flagged_increment: int = 0, + timed_out_increment: int = 0, + ): + self.done_regexes += 1 + self.current_type = regex_type + self.comparisons_done += comparisons + self.flagged_done += flagged_increment + self.timed_out_done += timed_out_increment + self.done_by_type[regex_type] = self.done_by_type.get(regex_type, 0) + 1 + self._render() + + def finish(self): + if not self.enabled: + return + self._render(force=True, done=True) + print(file=sys.stderr, flush=True) + + def _render(self, force: bool = False, done: bool = False): + if not self.enabled: + return + + now = time.monotonic() + if not force and not done and now - self.last_render_at < 0.1: + return + self.last_render_at = now + + ratio = min(1.0, self.done_regexes / self.total_regexes) + filled = int(ratio * self.BAR_WIDTH) + bar = "[" + ("=" * filled) + ("." * (self.BAR_WIDTH - filled)) + "]" + elapsed = max(0.001, now - self.started_at) + if done or ratio >= 1.0: + eta = 0.0 + else: + eta = (elapsed / max(ratio, 1e-9)) - elapsed + + type_done = self.done_by_type.get(self.current_type, 0) + type_total = self.totals_by_type.get(self.current_type, 0) + status = ( + "\r" + f"{bar} {ratio * 100:6.2f}% " + f"| regex {self.done_regexes}/{self.total_regexes} " + f"| type {self.current_type} {type_done}/{type_total} " + f"| flagged {self.flagged_done} " + f"| timed out {self.timed_out_done} " + f"| cmp {self.comparisons_done:,} " + f"| ETA {self._format_duration(eta)}" + ) + print(status, end="", file=sys.stderr, flush=True) + + @staticmethod + def _format_duration(seconds: float) -> str: + total_seconds = max(0, int(seconds)) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Audit accepted regexes against the current benign corpus and " + "optionally delete those whose strongest benign match meets or " + "exceeds the configured threshold." + ) + ) + parser.add_argument( + "--run-output-dir", + default="", + help=( + "Slips run output directory containing regex_generator/*.sqlite, " + "or a direct regex store directory containing generated_regexes.sqlite " + "and benign_corpus.sqlite." + ), + ) + parser.add_argument( + "--regex-db", + default="", + help="Path to generated_regexes.sqlite. Overrides --run-output-dir.", + ) + parser.add_argument( + "--benign-db", + default="", + help="Path to benign_corpus.sqlite. Overrides --run-output-dir.", + ) + parser.add_argument( + "--threshold", + type=float, + default=None, + help=( + "Benign match-strength threshold. Defaults to " + "regex_generator.benign_match_strength_threshold from config, " + "or 75 if unavailable." + ), + ) + parser.add_argument( + "--regex-type", + action="append", + choices=sorted(REGEX_TYPES), + help="Limit the audit to one or more regex types.", + ) + parser.add_argument( + "--match-timeout-seconds", + type=float, + default=None, + help=( + "Maximum wall-clock seconds allowed for one accepted regex to scan " + "the benign corpus for its regex type. Timed-out regexes are " + "skipped and never deleted. Defaults to " + "regex_generator.regex_validation_timeout_seconds from config, " + "or 2.0 if unavailable. Set 0 to disable." + ), + ) + parser.add_argument( + "--limit", + type=int, + default=20, + help="Maximum number of example rows to print per regex type.", + ) + parser.add_argument( + "--output-json", + default="", + help="Optional JSON output path for the audit summary.", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete accepted regex rows that exceed the threshold.", + ) + parser.add_argument( + "--no-backup", + action="store_true", + help="Do not create a backup copy of generated_regexes.sqlite before deletion.", + ) + parser.add_argument( + "--vacuum", + action="store_true", + help="Run VACUUM on generated_regexes.sqlite after deletion.", + ) + return parser.parse_args() + + +def default_threshold() -> float: + try: + return float( + ConfigParser().regex_generator_benign_match_strength_threshold() + ) + except Exception: + return 75.0 + + +def default_match_timeout() -> float: + try: + return float(ConfigParser().regex_generator_regex_validation_timeout_seconds()) + except Exception: + return 2.0 + + +def resolve_paths(args: argparse.Namespace) -> tuple[Path, Path]: + if args.regex_db and args.benign_db: + return Path(args.regex_db).expanduser(), Path(args.benign_db).expanduser() + + if not args.run_output_dir: + raise SystemExit( + "Provide either --regex-db and --benign-db, or --run-output-dir." + ) + + base = Path(args.run_output_dir).expanduser() + direct_regex = base / "generated_regexes.sqlite" + direct_benign = base / "benign_corpus.sqlite" + nested_regex = base / "regex_generator" / "generated_regexes.sqlite" + nested_benign = base / "regex_generator" / "benign_corpus.sqlite" + + if direct_regex.exists() and direct_benign.exists(): + return direct_regex, direct_benign + if nested_regex.exists() and nested_benign.exists(): + return nested_regex, nested_benign + + raise SystemExit( + "Could not find regex DBs. Checked:\n" + f"- {direct_regex} and {direct_benign}\n" + f"- {nested_regex} and {nested_benign}" + ) + + +def load_benign_values(benign_db_path: Path) -> dict[str, list[str]]: + benign_values = {regex_type: [] for regex_type in REGEX_TYPES} + with sqlite3.connect(benign_db_path) as conn: + rows = conn.execute( + "SELECT regex_type, value FROM benign_strings ORDER BY id ASC" + ) + for regex_type, value in rows: + benign_values.setdefault(regex_type, []).append(str(value or "")) + return benign_values + + +def load_accepted_regexes( + regex_db_path: Path, regex_types: set[str] +) -> dict[str, list[dict]]: + accepted = defaultdict(list) + with sqlite3.connect(regex_db_path) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT id, regex_type, regex, regex_hash, created_at + FROM generated_regexes + WHERE status = 'accepted' + ORDER BY created_at ASC, id ASC + """ + ).fetchall() + for row in rows: + regex_type = row["regex_type"] + if regex_type not in regex_types: + continue + accepted[regex_type].append(dict(row)) + return accepted + + +def audit_regex_type( + regex_rows: list[dict], + benign_values: list[str], + threshold: float, + match_timeout_seconds: float, + progress: AuditProgressTracker | None = None, +) -> tuple[list[RegexAuditResult], list[dict]]: + flagged = [] + timed_out = [] + for row in regex_rows: + comparisons_checked = 0 + flagged_increment = 0 + timed_out_increment = 0 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + compiled = re.compile(row["regex"]) + except re.error: + if progress is not None: + progress.advance( + row["regex_type"], + comparisons=comparisons_checked, + flagged_increment=flagged_increment, + timed_out_increment=timed_out_increment, + ) + continue + + regex_features = measure_regex_specificity(row["regex"]) + best_score = 0.0 + best_value = "" + try: + with timeout_context(match_timeout_seconds): + for value in benign_values: + comparisons_checked += 1 + score = compute_match_strength(compiled, value, regex_features) + if score > best_score: + best_score = score + best_value = value + if best_score >= threshold: + flagged_increment = 1 + flagged.append( + RegexAuditResult( + id=int(row["id"]), + regex_type=row["regex_type"], + regex=row["regex"], + regex_hash=row["regex_hash"], + created_at=float(row["created_at"]), + strongest_benign_score=best_score, + strongest_benign_value=best_value, + ) + ) + break + except TimeoutError: + timed_out_increment = 1 + timed_out.append( + { + "id": int(row["id"]), + "regex_type": row["regex_type"], + "regex": row["regex"], + "regex_hash": row["regex_hash"], + "created_at": float(row["created_at"]), + "comparisons_checked": comparisons_checked, + } + ) + if progress is not None: + progress.advance( + row["regex_type"], + comparisons=comparisons_checked, + flagged_increment=flagged_increment, + timed_out_increment=timed_out_increment, + ) + flagged.sort( + key=lambda item: ( + item.regex_type, + item.strongest_benign_score, + item.created_at, + item.id, + ), + reverse=True, + ) + timed_out.sort( + key=lambda item: ( + item["regex_type"], + item["created_at"], + item["id"], + ), + reverse=True, + ) + return flagged, timed_out + + +def backup_regex_db(regex_db_path: Path) -> Path: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_path = regex_db_path.with_suffix(regex_db_path.suffix + f".bak.{timestamp}") + shutil.copy2(regex_db_path, backup_path) + return backup_path + + +def delete_flagged_regexes( + regex_db_path: Path, flagged_results: list[RegexAuditResult], vacuum: bool +) -> int: + ids = [result.id for result in flagged_results] + if not ids: + return 0 + + placeholders = ",".join("?" for _ in ids) + with sqlite3.connect(regex_db_path) as conn: + cursor = conn.execute( + f"DELETE FROM generated_regexes WHERE id IN ({placeholders})", + ids, + ) + deleted = int(cursor.rowcount or 0) + conn.commit() + if vacuum: + conn.execute("VACUUM") + return deleted + + +def build_summary( + regex_db_path: Path, + benign_db_path: Path, + threshold: float, + regex_types: list[str], + accepted_by_type: dict[str, list[dict]], + flagged_by_type: dict[str, list[RegexAuditResult]], + timed_out_by_type: dict[str, list[dict]], + limit: int, + deleted: int, + backup_path: Path | None, + match_timeout_seconds: float, +) -> dict: + summary_types = {} + for regex_type in regex_types: + flagged_rows = flagged_by_type.get(regex_type, []) + timed_out_rows = timed_out_by_type.get(regex_type, []) + summary_types[regex_type] = { + "accepted_count": len(accepted_by_type.get(regex_type, [])), + "flagged_count": len(flagged_rows), + "timed_out_count": len(timed_out_rows), + "examples": [ + { + **asdict(result), + "created_at_iso": datetime.fromtimestamp( + result.created_at, tz=timezone.utc + ).isoformat(), + } + for result in flagged_rows[:limit] + ], + "timed_out_examples": [ + { + **row, + "created_at_iso": datetime.fromtimestamp( + row["created_at"], tz=timezone.utc + ).isoformat(), + } + for row in timed_out_rows[:limit] + ], + } + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "regex_db_path": str(regex_db_path), + "benign_db_path": str(benign_db_path), + "threshold": threshold, + "match_timeout_seconds": match_timeout_seconds, + "regex_types": regex_types, + "deleted_count": deleted, + "backup_path": str(backup_path) if backup_path else "", + "totals": { + "accepted_count": sum( + len(accepted_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + "flagged_count": sum( + len(flagged_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + "timed_out_count": sum( + len(timed_out_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + }, + "types": summary_types, + } + + +def print_summary(summary: dict, delete_mode: bool): + action = "Deleted" if delete_mode else "Flagged" + print( + f"Threshold: {summary['threshold']:.2f}\n" + f"Match timeout per regex: {summary['match_timeout_seconds']:.2f}s\n" + f"Regex DB: {summary['regex_db_path']}\n" + f"Benign DB: {summary['benign_db_path']}\n" + f"Accepted rows scanned: {summary['totals']['accepted_count']}\n" + f"{action} rows: {summary['totals']['flagged_count']}\n" + f"Timed-out rows skipped: {summary['totals']['timed_out_count']}" + ) + print( + "Accepted means rows currently stored in generated_regexes.sqlite " + "with status='accepted'." + ) + if delete_mode: + print( + "Deleted means accepted rows whose strongest benign match score " + "met or exceeded the threshold and were removed." + ) + else: + print( + "Flagged means accepted rows whose strongest benign match score " + "meets or exceeds the threshold against the current benign corpus." + ) + if summary.get("backup_path"): + print(f"Backup: {summary['backup_path']}") + + for regex_type in summary["regex_types"]: + row = summary["types"][regex_type] + print( + f"\n[{regex_type}] accepted={row['accepted_count']} " + f"flagged={row['flagged_count']} " + f"timed_out={row['timed_out_count']}" + ) + for example in row["examples"]: + print( + " " + f"score={example['strongest_benign_score']:.2f} " + f"value={example['strongest_benign_value']} " + f"created_at={example['created_at_iso']} " + f"regex={example['regex']}" + ) + for example in row["timed_out_examples"]: + print( + " " + "timed_out " + f"after_cmp={example['comparisons_checked']} " + f"created_at={example['created_at_iso']} " + f"regex={example['regex']}" + ) + + +def main(): + args = parse_args() + regex_db_path, benign_db_path = resolve_paths(args) + threshold = ( + float(args.threshold) if args.threshold is not None else default_threshold() + ) + match_timeout_seconds = ( + float(args.match_timeout_seconds) + if args.match_timeout_seconds is not None + else default_match_timeout() + ) + regex_types = sorted(set(args.regex_type or REGEX_TYPES)) + + benign_values = load_benign_values(benign_db_path) + accepted_by_type = load_accepted_regexes(regex_db_path, set(regex_types)) + progress = AuditProgressTracker( + total_regexes=sum( + len(accepted_by_type.get(regex_type, [])) for regex_type in regex_types + ), + totals_by_type={ + regex_type: len(accepted_by_type.get(regex_type, [])) + for regex_type in regex_types + }, + ) + progress.start() + flagged_by_type = {} + timed_out_by_type = {} + for regex_type in regex_types: + flagged_rows, timed_out_rows = audit_regex_type( + accepted_by_type.get(regex_type, []), + benign_values.get(regex_type, []), + threshold, + match_timeout_seconds, + progress=progress, + ) + flagged_by_type[regex_type] = flagged_rows + timed_out_by_type[regex_type] = timed_out_rows + progress.finish() + + backup_path = None + deleted = 0 + flagged_results = [ + result + for regex_type in regex_types + for result in flagged_by_type.get(regex_type, []) + ] + if args.delete and flagged_results: + if not args.no_backup: + backup_path = backup_regex_db(regex_db_path) + deleted = delete_flagged_regexes(regex_db_path, flagged_results, args.vacuum) + + summary = build_summary( + regex_db_path=regex_db_path, + benign_db_path=benign_db_path, + threshold=threshold, + regex_types=regex_types, + accepted_by_type=accepted_by_type, + flagged_by_type=flagged_by_type, + timed_out_by_type=timed_out_by_type, + limit=max(0, args.limit), + deleted=deleted, + backup_path=backup_path, + match_timeout_seconds=match_timeout_seconds, + ) + print_summary(summary, delete_mode=args.delete) + + if args.output_json: + output_path = Path(args.output_json).expanduser() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() From 8da04aa11d4ef58bf45380f659d451b2f0291d65 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:52 +0200 Subject: [PATCH 0179/1100] feat: enhance report HTML assertions for regex and co-stimulation states --- tests/unit/modules/t_cell/test_analyze_t_cell.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index ba4812dd59..6767cd269b 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -351,7 +351,11 @@ def test_build_report_payload_and_html(tmp_path): assert "Quick Summary" in html assert "Decision Trace" in html assert "T Cell State Machine" in html - assert "regex match" in html + assert "accepted regex match" in html + assert "no accepted regex match" in html + assert "stays mature" in html + assert "co-stimulation below threshold" in html + assert "no co-stimulation timeout" in html assert "current cells:" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html From 103d744df39858aada43bab868f2866ccd68aee2 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sun, 19 Apr 2026 09:56:07 +0200 Subject: [PATCH 0180/1100] feat: enable decision trace mode for T Cell responder module --- config/slips.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/slips.yaml b/config/slips.yaml index 2a2c6443a7..8dff210872 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -351,7 +351,7 @@ t_cell: # off = disabled # transitions = write detailed traces only when a state transition happens # all = also trace waiting evaluations - decision_trace_mode: off + decision_trace_mode: on # Separate trace file used only when decision_trace_mode is not off. # This path is always resolved inside the selected output directory for the From cdaadde1764e2e5f6926cc54adce28536a8f3dd4 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sun, 19 Apr 2026 09:56:25 +0200 Subject: [PATCH 0181/1100] Implement feature X to enhance user experience and fix bug Y in module Z --- modules/t_cell/analyze_t_cell.py | 1779 +++++++++++++++++++++++++++++- 1 file changed, 1732 insertions(+), 47 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index d0facc12e5..9ba9a80038 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -62,6 +62,29 @@ "co_stimulation": "waiting for co-stimulation", "context": "waiting for context", } +DEFAULT_COSTIM_WEIGHTS = { + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, +} +DEFAULT_DOC_CONFIG = { + "anergy_ttl_seconds": 21600.0, + "related_lookback_seconds": 3600.0, + "related_pamps_saturation": 5.0, + "danger_saturation": 2.5, + "damp_danger_weight": 1.5, + "co_stimulation_threshold": 0.65, + "co_stimulation_weights": DEFAULT_COSTIM_WEIGHTS, + "novelty_window_seconds": 86400.0, + "context_recent_window_seconds": 1800.0, + "effector_threshold": 0.70, + "effector_min_related_count": 4, + "effector_cooldown_seconds": 1800.0, + "memory_threshold": 0.60, + "memory_trend_ratio_max": 0.60, + "memory_min_related_count": 3, + "state_wait_timeout_seconds": 3600.0, +} def parse_args() -> argparse.Namespace: @@ -451,6 +474,54 @@ def safe_div(num: float, den: float) -> float: return num / den +def normalize_costim_weights(weights: Any) -> dict[str, float]: + if not isinstance(weights, dict): + weights = {} + sanitized = {} + for key, default_value in DEFAULT_COSTIM_WEIGHTS.items(): + raw_value = weights.get(key, default_value) + try: + raw_value = float(raw_value) + except (TypeError, ValueError): + raw_value = default_value + sanitized[key] = max(0.0, raw_value) + + total = sum(sanitized.values()) + if total <= 0: + total = sum(DEFAULT_COSTIM_WEIGHTS.values()) + sanitized = DEFAULT_COSTIM_WEIGHTS.copy() + return {key: value / total for key, value in sanitized.items()} + + +def coerce_time_window_width(raw_value: Any) -> float: + if raw_value in (None, ""): + return float(DEFAULT_DOC_CONFIG["state_wait_timeout_seconds"]) + try: + return float(raw_value) + except (TypeError, ValueError): + text = str(raw_value) + if "only_one_tw" in text: + return 9999999999.0 + return float(DEFAULT_DOC_CONFIG["state_wait_timeout_seconds"]) + + +def report_config_with_defaults(report: dict) -> dict: + config = dict(report.get("config") or {}) + merged = {} + for key, default_value in DEFAULT_DOC_CONFIG.items(): + raw_value = config.get(key, default_value) + if key == "co_stimulation_weights": + merged[key] = normalize_costim_weights(raw_value) + continue + if key == "state_wait_timeout_seconds": + merged[key] = coerce_time_window_width(raw_value) + continue + if raw_value in (None, "", {}): + raw_value = default_value + merged[key] = raw_value + return merged + + def build_findings(report: dict) -> list[str]: totals = report["totals"] categories = report["observation_categories"] @@ -612,6 +683,523 @@ def bucket_items( } +def trace_row_cell_key(entry: dict) -> str: + if entry.get("cell_key"): + return str(entry.get("cell_key")) + candidate = entry.get("candidate") or {} + responsible_ip = str(entry.get("responsible_ip") or "") + regex_type = str(candidate.get("regex_type") or "") + antigen_value = str(candidate.get("value") or "") + if responsible_ip and regex_type and antigen_value: + return f"{responsible_ip}|{regex_type}|{antigen_value}" + return "" + + +def describe_current_evidence(current_evidence: dict | None) -> str: + current_evidence = current_evidence or {} + evidence_id = current_evidence.get("evidence_id") or "n/a" + evidence_type = current_evidence.get("evidence_type") or "unknown" + signal = current_evidence.get("signal") or "unknown" + confidence = format_float(current_evidence.get("confidence")) + threat_level = current_evidence.get("threat_level") or "unknown" + threat_level_value = format_float(current_evidence.get("threat_level_value")) + danger = format_float(current_evidence.get("danger_contribution")) + observation_id = current_evidence.get("observation_id") + observation_part = ( + f"obs={observation_id} | " if observation_id not in (None, "") else "" + ) + return ( + f"{observation_part}eid={evidence_id} | {evidence_type} | {signal} | " + f"conf={confidence} | threat={threat_level} ({threat_level_value}) | " + f"danger={danger}" + ) + + +def describe_observation_row(observation: dict | None) -> str: + observation = observation or {} + if not observation: + return "no linked observation row" + return ( + f"obs={observation.get('id')} | eid={observation.get('evidence_id')} | " + f"{observation.get('evidence_type')} | {observation.get('evidence_signal')} | " + f"conf={format_float(observation.get('confidence'))} | " + f"threat={observation.get('threat_level')} " + f"({format_float(observation.get('threat_level_value'))}) | " + f"antigens={summarize_antigens(observation.get('antigens') or [])} | " + f"matches={summarize_matched_regexes(observation.get('matched_regexes') or [])}" + ) + + +def describe_trace_contributor(prefix: str, contributor: dict) -> str: + relations = contributor.get("relations") or [] + relations_text = f" | relations={','.join(relations)}" if relations else "" + return ( + f"{prefix}: obs={contributor.get('observation_id')} | " + f"eid={contributor.get('evidence_id')} | {contributor.get('evidence_type')} | " + f"{contributor.get('signal')} | conf={format_float(contributor.get('confidence'))} | " + f"threat={contributor.get('threat_level')} " + f"({format_float(contributor.get('threat_level_value'))}) | " + f"danger={format_float(contributor.get('danger_contribution'))}" + f"{relations_text}" + ) + + +def summarize_lines(lines: list[str], fallback: str = "n/a", limit: int = 2) -> str: + cleaned = [str(line).strip() for line in lines if str(line).strip()] + if not cleaned: + return fallback + summary = " | ".join(cleaned[:limit]) + if len(cleaned) > limit: + summary += f" | +{len(cleaned) - limit} more" + return summary + + +def generic_threshold_result(scores: dict) -> tuple[str, list[str]]: + if not isinstance(scores, dict): + return ("n/a", ["No threshold snapshot was stored for this transition."]) + + if "value" in scores and "threshold" in scores: + value = scores.get("value") + threshold = scores.get("threshold") + passed = float(value) >= float(threshold) + comparator = ">=" if passed else "<" + status = "passed" if passed else "failed" + return ( + f"{status}: {format_float(value)} {comparator} {format_float(threshold)}", + [ + f"value={format_float(value)}", + f"threshold={format_float(threshold)}", + ], + ) + + result_lines = [] + summary_bits = [] + if "effector_score" in scores and "effector_threshold" in scores: + passed = float(scores["effector_score"]) >= float(scores["effector_threshold"]) + summary_bits.append( + "effector " + + ("passed" if passed else "failed") + + f": {format_float(scores['effector_score'])} " + + (">=" if passed else "<") + + f" {format_float(scores['effector_threshold'])}" + ) + result_lines.append(summary_bits[-1]) + if "memory_score" in scores and "memory_threshold" in scores: + passed = float(scores["memory_score"]) >= float(scores["memory_threshold"]) + summary_bits.append( + "memory " + + ("passed" if passed else "failed") + + f": {format_float(scores['memory_score'])} " + + (">=" if passed else "<") + + f" {format_float(scores['memory_threshold'])}" + ) + result_lines.append(summary_bits[-1]) + if not summary_bits: + return ("n/a", ["No threshold keys were stored in this score snapshot."]) + return (" | ".join(summary_bits), result_lines) + + +def build_trace_threshold_result(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + action = entry.get("action") or "" + if stage == "co_stimulation": + value = formula.get("value") + threshold = formula.get("threshold") + if value is None or threshold is None: + return ("n/a", ["Missing co-stimulation value or threshold."]) + passed = float(value) >= float(threshold) + comparator = ">=" if passed else "<" + return ( + f"{'passed' if passed else 'failed'}: " + f"{format_float(value)} {comparator} {format_float(threshold)}", + [ + f"action={action}", + f"value={format_float(value)}", + f"threshold={format_float(threshold)}", + ], + ) + + if stage == "context": + decision = formula.get("decision") or {} + effector = bool(decision.get("effector")) + memory = bool(decision.get("memory")) + effector_score = formula.get("effector_score") + effector_threshold = formula.get("effector_threshold") + memory_score = formula.get("memory_score") + memory_threshold = formula.get("memory_threshold") + summary = ( + f"effector={'yes' if effector else 'no'} " + f"({format_float(effector_score)} / {format_float(effector_threshold)}) | " + f"memory={'yes' if memory else 'no'} " + f"({format_float(memory_score)} / {format_float(memory_threshold)})" + ) + return ( + summary, + [ + f"action={action}", + f"effector decision={'passed' if effector else 'failed'}", + f"memory decision={'passed' if memory else 'failed'}", + f"effector_score={format_float(effector_score)} threshold={format_float(effector_threshold)}", + f"memory_score={format_float(memory_score)} threshold={format_float(memory_threshold)}", + ], + ) + return ("n/a", ["No threshold formatter for this trace stage."]) + + +def build_trace_considered_evidence(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + lines = [] + current_evidence = entry.get("current_evidence") or {} + if current_evidence: + lines.append("current: " + describe_current_evidence(current_evidence)) + + components = formula.get("components") or {} + if stage == "co_stimulation": + related = (components.get("related_pamps") or {}).get("contributors") or [] + danger = components.get("danger") or {} + pamp_contributors = danger.get("pamp_contributors") or [] + damp_contributors = danger.get("damp_contributors") or [] + for contributor in related: + lines.append(describe_trace_contributor("related_pamp", contributor)) + for contributor in pamp_contributors: + lines.append(describe_trace_contributor("danger_pamp", contributor)) + for contributor in damp_contributors: + lines.append(describe_trace_contributor("danger_damp", contributor)) + elif stage == "context": + recent_related = (components.get("recent_related") or {}).get( + "contributors" + ) or [] + recent_pressure = components.get("recent_pressure") or {} + previous_pressure = components.get("previous_pressure") or {} + for contributor in recent_related: + lines.append(describe_trace_contributor("recent_related", contributor)) + for contributor in recent_pressure.get("pamp_contributors") or []: + lines.append(describe_trace_contributor("recent_pressure_pamp", contributor)) + for contributor in recent_pressure.get("damp_contributors") or []: + lines.append(describe_trace_contributor("recent_pressure_damp", contributor)) + for contributor in previous_pressure.get("pamp_contributors") or []: + lines.append(describe_trace_contributor("previous_pressure_pamp", contributor)) + for contributor in previous_pressure.get("damp_contributors") or []: + lines.append(describe_trace_contributor("previous_pressure_damp", contributor)) + + if not lines: + lines.append("No contributor evidence snapshot was stored for this event.") + return (summarize_lines(lines, fallback="no stored evidence inputs"), lines) + + +def build_trace_computation_lines(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + if stage == "co_stimulation": + components = formula.get("components") or {} + confidence = components.get("confidence") or {} + related = components.get("related_pamps") or {} + danger = components.get("danger") or {} + lines = [ + f"value={format_float(formula.get('value'))}", + f"threshold={format_float(formula.get('threshold'))}", + ( + "confidence: value=" + f"{format_float(confidence.get('value'))} weighted=" + f"{format_float(confidence.get('weighted'))}" + ), + ( + "related_pamps: count=" + f"{related.get('count', 'n/a')} saturation=" + f"{format_float(related.get('saturation'))} score=" + f"{format_float(related.get('score'))} weighted=" + f"{format_float(related.get('weighted'))}" + ), + ( + "danger: score=" + f"{format_float(danger.get('score'))} weighted=" + f"{format_float(danger.get('weighted'))} pamp_score=" + f"{format_float(danger.get('pamp_score'))} damp_score=" + f"{format_float(danger.get('damp_score'))} damp_weight=" + f"{format_float(danger.get('damp_weight'))} saturation=" + f"{format_float(danger.get('danger_saturation'))}" + ), + ] + return (summarize_trace_formula(formula, stage), lines) + + if stage == "context": + components = formula.get("components") or {} + novelty = components.get("novelty") or {} + recent_related = components.get("recent_related") or {} + recent_pressure = components.get("recent_pressure") or {} + previous_pressure = components.get("previous_pressure") or {} + lines = [ + ( + "effector_score=" + f"{format_float(formula.get('effector_score'))} threshold=" + f"{format_float(formula.get('effector_threshold'))}" + ), + ( + "memory_score=" + f"{format_float(formula.get('memory_score'))} threshold=" + f"{format_float(formula.get('memory_threshold'))}" + ), + ( + "decision flags: effector=" + f"{'yes' if (formula.get('decision') or {}).get('effector') else 'no'} " + "memory=" + f"{'yes' if (formula.get('decision') or {}).get('memory') else 'no'}" + ), + ( + "novelty: score=" + f"{format_float(novelty.get('score'))} has_memory=" + f"{'yes' if novelty.get('has_memory_for_regex') else 'no'} " + "recent_activity=" + f"{'yes' if novelty.get('has_recent_regex_activity') else 'no'}" + ), + ( + "recent_related: count=" + f"{recent_related.get('count', 'n/a')} saturation=" + f"{format_float(recent_related.get('saturation'))} score=" + f"{format_float(recent_related.get('score'))}" + ), + ( + "recent_pressure: combined=" + f"{format_float(recent_pressure.get('combined_score'))} pamp=" + f"{format_float(recent_pressure.get('pamp_score'))} damp=" + f"{format_float(recent_pressure.get('damp_score'))} " + "raw_pamp=" + f"{format_float(recent_pressure.get('pamp_total_raw'))} raw_damp=" + f"{format_float(recent_pressure.get('damp_total_raw'))}" + ), + ( + "previous_pressure: combined=" + f"{format_float(previous_pressure.get('combined_score'))} pamp=" + f"{format_float(previous_pressure.get('pamp_score'))} damp=" + f"{format_float(previous_pressure.get('damp_score'))} " + "raw_pamp=" + f"{format_float(previous_pressure.get('pamp_total_raw'))} raw_damp=" + f"{format_float(previous_pressure.get('damp_total_raw'))}" + ), + f"trend_ratio={format_float(components.get('trend_ratio'))}", + f"decrease_score={format_float(components.get('decrease_score'))}", + f"familiarity_score={format_float(components.get('familiarity_score'))}", + f"stability_score={format_float(components.get('stability_score'))}", + ] + return (summarize_trace_formula(formula, stage), lines) + + return ("n/a", ["No computation formatter for this trace stage."]) + + +def build_transition_computation_lines(transition: dict) -> tuple[str, list[str]]: + scores = transition.get("scores") or {} + if not scores: + return ("no score snapshot", ["This transition stored no score payload."]) + lines = [f"{key}={format_float(value)}" for key, value in sorted(scores.items())] + return (summarize_lines(lines, fallback="score snapshot"), lines) + + +def build_transition_event( + transition: dict, observations_by_id: dict[int, dict] +) -> dict: + observation = observations_by_id.get(int(transition.get("observation_id") or 0), {}) + threshold_summary, threshold_lines = generic_threshold_result( + transition.get("scores") or {} + ) + computation_summary, computation_lines = build_transition_computation_lines( + transition + ) + evidence_lines = [describe_observation_row(observation)] + return { + "ts": transition.get("created_at"), + "wall": ts_to_iso(transition.get("created_at")), + "source": "State transition", + "step": transition.get("reason") or "transition", + "stage": "transition", + "state_path": ( + f"{state_label(transition.get('from_state'))} → " + f"{state_label(transition.get('to_state'))}" + ), + "evidence_id": transition.get("evidence_id") or "", + "threshold_summary": threshold_summary, + "threshold_lines": threshold_lines, + "considered_summary": summarize_lines(evidence_lines), + "considered_lines": evidence_lines, + "computation_summary": computation_summary, + "computation_lines": computation_lines, + "priority": 2, + } + + +def build_trace_event(entry: dict) -> dict: + threshold_summary, threshold_lines = build_trace_threshold_result(entry) + considered_summary, considered_lines = build_trace_considered_evidence(entry) + computation_summary, computation_lines = build_trace_computation_lines(entry) + current_evidence = entry.get("current_evidence") or {} + evidence_id = current_evidence.get("evidence_id") or "" + evidence_type = current_evidence.get("evidence_type") or "" + signal = current_evidence.get("signal") or "" + if evidence_type or signal: + evidence_label = f"{evidence_id} | {evidence_type} | {signal}".strip(" |") + else: + evidence_label = evidence_id or "n/a" + return { + "ts": entry.get("_ts"), + "wall": entry.get("ts") or ts_to_iso(entry.get("_ts")), + "source": "Decision trace", + "step": f"{entry.get('stage') or 'trace'}: {entry.get('action') or 'event'}", + "stage": entry.get("stage") or "trace", + "state_path": ( + f"{entry.get('from_state') or 'n/a'} → {entry.get('to_state') or 'n/a'}" + ), + "evidence_id": evidence_label, + "threshold_summary": threshold_summary, + "threshold_lines": threshold_lines, + "considered_summary": considered_summary, + "considered_lines": considered_lines, + "computation_summary": computation_summary, + "computation_lines": computation_lines, + "priority": 1, + } + + +def build_life_path( + transitions_for_cell: list[dict], current_state_label: str | None +) -> str: + ordered = sorted( + transitions_for_cell, + key=lambda item: (float(item.get("created_at") or 0.0), int(item.get("id") or 0)), + ) + states = [] + for transition in ordered: + from_label = state_label(transition.get("from_state")) + to_label = state_label(transition.get("to_state")) + if not states: + states.append(from_label) + if states[-1] != from_label: + states.append(from_label) + if states[-1] != to_label: + states.append(to_label) + if not states and current_state_label: + states = [current_state_label] + elif current_state_label and states[-1] != current_state_label: + states.append(current_state_label) + return " → ".join(states) if states else "no recorded state changes" + + +def build_cell_histories( + observations: list[dict], + cells: list[dict], + transitions: list[dict], + trace_rows: list[dict], +) -> list[dict]: + observations_by_id = { + int(observation["id"]): observation + for observation in observations + if observation.get("id") is not None + } + cells_by_key = { + str(cell.get("cell_key")): cell + for cell in cells + if cell.get("cell_key") + } + transitions_by_cell: dict[str, list[dict]] = defaultdict(list) + for transition in transitions: + cell_key = str(transition.get("cell_key") or "") + if cell_key: + transitions_by_cell[cell_key].append(transition) + + traces_by_cell: dict[str, list[dict]] = defaultdict(list) + for entry in trace_rows: + cell_key = trace_row_cell_key(entry) + if cell_key: + traces_by_cell[cell_key].append(entry) + + cell_keys = set(cells_by_key) | set(transitions_by_cell) | set(traces_by_cell) + histories = [] + for cell_key in sorted(cell_keys): + cell = cells_by_key.get(cell_key, {}) + cell_transitions = transitions_by_cell.get(cell_key, []) + cell_traces = traces_by_cell.get(cell_key, []) + + events = [build_trace_event(entry) for entry in cell_traces] + events.extend( + build_transition_event(transition, observations_by_id) + for transition in cell_transitions + ) + events.sort( + key=lambda item: ( + item.get("ts") is None, + float(item.get("ts") or 0.0), + int(item.get("priority") or 9), + item.get("step") or "", + ) + ) + + current_state_label = state_label(cell.get("state")) if cell else None + waiting_label = cell_waiting_label(cell) if cell else "" + first_ts_candidates = [ + float(item.get("ts")) + for item in events + if item.get("ts") is not None + ] + if cell.get("created_at") is not None: + first_ts_candidates.append(float(cell.get("created_at"))) + last_ts_candidates = [ + float(item.get("ts")) + for item in events + if item.get("ts") is not None + ] + if cell.get("updated_at") is not None: + last_ts_candidates.append(float(cell.get("updated_at"))) + first_seen = min(first_ts_candidates) if first_ts_candidates else None + last_seen = max(last_ts_candidates) if last_ts_candidates else None + current_state_display = current_state_label or "unknown" + if waiting_label: + current_state_display += f" ({waiting_label})" + + histories.append( + { + "cell_key": cell_key, + "responsible_ip": cell.get("responsible_ip") + or ( + cell_transitions[0].get("profile_ip") + if cell_transitions + else (cell_traces[0].get("responsible_ip") if cell_traces else "") + ), + "regex_type": cell.get("regex_type") + or ( + cell_transitions[0].get("regex_type") + if cell_transitions + else ((cell_traces[0].get("candidate") or {}).get("regex_type", "")) + ), + "antigen_value": cell.get("antigen_value") + or ( + cell_transitions[0].get("antigen_value") + if cell_transitions + else ((cell_traces[0].get("candidate") or {}).get("value", "")) + ), + "matched_value": cell.get("matched_value") + or ( + cell_transitions[-1].get("matched_value") + if cell_transitions + else ((cell_traces[-1].get("match") or {}).get("value", "")) + ), + "current_state": current_state_display, + "current_state_class": state_class(cell.get("state")) + if cell + else "state-unknown", + "waiting_label": waiting_label, + "life_path": build_life_path(cell_transitions, current_state_label), + "first_seen": ts_to_iso(first_seen), + "last_seen": ts_to_iso(last_seen), + "event_count": len(events), + "transition_count": len(cell_transitions), + "trace_count": len(cell_traces), + "events": events, + } + ) + + return histories + + def build_report_payload( run_output_dir: Path, max_observations: int = 200, @@ -631,7 +1219,9 @@ def build_report_payload( memories = db_records["memories"] log_data = load_log_entries(log_path, max_log_lines) trace_rows = load_trace_entries(trace_path) - config = load_yaml_config(metadata_path).get("t_cell", {}) + metadata = load_yaml_config(metadata_path) + config = metadata.get("t_cell", {}) + parameters = metadata.get("parameters", {}) transitions_by_observation: dict[int, list[dict]] = defaultdict(list) for transition in transitions: @@ -825,6 +1415,12 @@ def build_report_payload( } for entry in log_data["entries"][-max(1, max_log_lines) :] ] + cell_histories = build_cell_histories( + observations=observations, + cells=cells, + transitions=transitions, + trace_rows=trace_rows, + ) report = { "generated_at": now_iso(), @@ -843,11 +1439,29 @@ def build_report_payload( "log_verbosity": config.get("log_verbosity"), "decision_trace_mode": config.get("decision_trace_mode"), "related_lookback_seconds": config.get("related_lookback_seconds"), + "related_pamps_saturation": config.get("related_pamps_saturation"), + "danger_saturation": config.get("danger_saturation"), + "damp_danger_weight": config.get("damp_danger_weight"), "co_stimulation_threshold": config.get("co_stimulation_threshold"), + "co_stimulation_weights": normalize_costim_weights( + config.get("co_stimulation_weights") + ), + "novelty_window_seconds": config.get("novelty_window_seconds"), + "context_recent_window_seconds": config.get( + "context_recent_window_seconds" + ), "effector_threshold": config.get("effector_threshold"), + "effector_min_related_count": config.get( + "effector_min_related_count" + ), "memory_threshold": config.get("memory_threshold"), + "memory_trend_ratio_max": config.get("memory_trend_ratio_max"), + "memory_min_related_count": config.get("memory_min_related_count"), "anergy_ttl_seconds": config.get("anergy_ttl_seconds"), "effector_cooldown_seconds": config.get("effector_cooldown_seconds"), + "state_wait_timeout_seconds": coerce_time_window_width( + parameters.get("time_window_width") + ), }, "totals": { "observations": len(observations), @@ -893,6 +1507,7 @@ def build_report_payload( "rows": recent_trace_rows[: max(1, max_trace_rows)], "total_rows": len(trace_rows), }, + "cell_histories": cell_histories, "log": { "rows": recent_log_rows, "tail_text": "\n".join(log_data["tail"]), @@ -1430,6 +2045,723 @@ def render_pretty_json(value: Any) -> str: return escape(json.dumps(value, indent=2, sort_keys=True)) +def render_formula_box(lines: list[str]) -> str: + return ( + "
    "
    +        + escape("\n".join(lines))
    +        + "
    " + ) + + +def render_term_cards(terms: list[dict]) -> str: + return "".join( + f""" +
    +

    {escape(term['label'])}

    +

    {escape(term['formula'])}

    +

    {escape(term['description'])}

    +
    + """ + for term in terms + ) + + +def render_formula_tree_node(node: dict) -> str: + children = node.get("children") or [] + child_class = "formula-children" + if len(children) > 1: + child_class += " has-multiple" + tooltip = node.get("tooltip") or "" + formula = node.get("formula") or "" + summary = node.get("summary") or "" + children_html = "" + if children: + children_html = ( + f"
    " + + "".join( + "
    " + + render_formula_tree_node(child) + + "
    " + for child in children + ) + + "
    " + ) + return f""" +
    +
    + {escape(node.get('label', 'value'))} + {f"{escape(formula)}" if formula else ""} + {f"{escape(summary)}" if summary else ""} + {f"{escape(tooltip)}" if tooltip else ""} +
    + {children_html} +
    + """ + + +def render_formula_tree(node: dict) -> str: + return f"
    {render_formula_tree_node(node)}
    " + + +def render_decision_doc_card(card: dict) -> str: + equation_html = render_formula_box(card["equation_lines"]) + gate_html = render_formula_box(card["gate_lines"]) + term_cards_html = render_term_cards(card["terms"]) + tree_html = render_formula_tree(card["tree"]) + notes_html = "".join( + f"

    {escape(note)}

    " + for note in card.get("notes", []) + ) + return f""" +
    +
    +

    {escape(card['title'])}

    +

    {escape(card['summary'])}

    +
    +
    +
    +

    Exact Equation

    + {equation_html} +
    +
    +

    Decision Gate

    + {gate_html} +
    +
    + {notes_html} +
    + {term_cards_html} +
    +
    +
    +

    Input Tree

    +

    Hover or focus a node to see where that term comes from.

    +
    + {tree_html} +
    +
    + """ + + +def render_rule_cards(cards: list[dict]) -> str: + return "".join( + f""" +
    +

    {escape(card['title'])}

    +

    {escape(card['rule'])}

    +

    {escape(card['description'])}

    +
    + """ + for card in cards + ) + + +def render_decision_reference(report: dict) -> str: + config = report_config_with_defaults(report) + weights = config["co_stimulation_weights"] + related_lookback = format_float(config["related_lookback_seconds"]) + related_saturation = format_float(config["related_pamps_saturation"]) + danger_saturation = format_float(config["danger_saturation"]) + damp_weight = format_float(config["damp_danger_weight"]) + novelty_window = format_float(config["novelty_window_seconds"]) + context_window = format_float(config["context_recent_window_seconds"]) + wait_limit = format_float(config["state_wait_timeout_seconds"]) + co_threshold = format_float(config["co_stimulation_threshold"]) + effector_threshold = format_float(config["effector_threshold"]) + effector_min_related = str(int(config["effector_min_related_count"])) + effector_cooldown = format_float(config["effector_cooldown_seconds"]) + memory_threshold = format_float(config["memory_threshold"]) + memory_ratio_max = format_float(config["memory_trend_ratio_max"]) + memory_min_related = str(int(config["memory_min_related_count"])) + anergy_ttl = format_float(config["anergy_ttl_seconds"]) + + decision_cards = [ + { + "title": "Co-Stimulation: 1 -> 3 activation", + "summary": "This score is evaluated after antigen recognition to decide whether the cell activates.", + "equation_lines": [ + ( + "co_stimulation = " + f"{format_float(weights['confidence'])} * confidence" + ), + ( + f" + {format_float(weights['related_pamps'])} " + "* related_pamp_score" + ), + ( + f" + {format_float(weights['danger'])} " + "* profile_danger_score" + ), + ], + "gate_lines": [ + f"activate when co_stimulation >= {co_threshold}", + "otherwise stay in 1 - antigen-recognized", + f"timeout to 2 - anergic after {wait_limit}s if still below threshold", + ], + "notes": [ + f"Related PAMPs are counted over the last {related_lookback}s for the same responsible IP.", + "A related PAMP shares either the same antigen value or the same matched regex hash. The current observation is excluded from that count.", + "DAMP observations never create cells, but they do raise the mixed danger term used here.", + ], + "terms": [ + { + "label": "confidence", + "formula": "current evidence.confidence", + "description": "The confidence carried by the observation that is being evaluated right now.", + }, + { + "label": "related_pamp_score", + "formula": f"clamp01(related_pamp_count / {related_saturation})", + "description": "How much recent, related PAMP evidence reinforces the same antigen or regex identity.", + }, + { + "label": "profile_danger_score", + "formula": ( + "clamp01((pamp_raw + " + f"{damp_weight} * damp_raw) / {danger_saturation})" + ), + "description": "The mixed danger pressure for the same responsible IP, with DAMP raw danger amplified before normalization.", + }, + { + "label": "pamp_raw / damp_raw", + "formula": "sum(threat_level_value * confidence)", + "description": "Raw danger is the sum of threat level value multiplied by confidence across recent PAMP or DAMP observations.", + }, + ], + "tree": { + "label": "co_stimulation", + "formula": ( + f"{format_float(weights['confidence'])} * confidence + " + f"{format_float(weights['related_pamps'])} * related_pamp_score + " + f"{format_float(weights['danger'])} * profile_danger_score" + ), + "summary": f"Activation score. Threshold = {co_threshold}", + "tooltip": "Final co-stimulation score used for the 1 -> 3 decision.", + "children": [ + { + "label": "confidence", + "formula": "current evidence.confidence", + "summary": "Current PAMP confidence", + "tooltip": "Read directly from the observation currently being processed.", + }, + { + "label": "related_pamp_score", + "formula": f"clamp01(related_pamp_count / {related_saturation})", + "summary": "Recent related PAMP reinforcement", + "tooltip": "Normalized count of related PAMP observations in the related lookback window.", + "children": [ + { + "label": "related_pamp_count", + "formula": "count of related recent PAMPs", + "summary": "Same antigen value or same matched regex hash", + "tooltip": ( + f"Counted over the last {related_lookback}s for the same responsible IP. " + "The current observation is excluded." + ), + }, + { + "label": "related_pamps_saturation", + "formula": related_saturation, + "summary": "Count where the score saturates at 1", + "tooltip": "If the count reaches this value, related_pamp_score stops increasing.", + }, + ], + }, + { + "label": "profile_danger_score", + "formula": ( + "clamp01((pamp_raw + " + f"{damp_weight} * damp_raw) / {danger_saturation})" + ), + "summary": "Normalized mixed danger for the responsible IP", + "tooltip": "Recent PAMP and DAMP danger are combined, then clamped into the 0..1 range.", + "children": [ + { + "label": "pamp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent PAMP raw danger", + "tooltip": ( + f"Summed over PAMP observations for the same responsible IP within the last {related_lookback}s." + ), + }, + { + "label": "damp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent DAMP raw danger", + "tooltip": ( + f"Summed over DAMP observations for the same responsible IP within the last {related_lookback}s." + ), + }, + { + "label": "damp_danger_weight", + "formula": damp_weight, + "summary": "Amplifies DAMP raw danger before normalization", + "tooltip": "DAMP pressure is scaled before it is added into the mixed danger term.", + }, + { + "label": "danger_saturation", + "formula": danger_saturation, + "summary": "Raw danger amount that maps to score 1", + "tooltip": "The combined raw danger is divided by this value before clamp01 is applied.", + }, + ], + }, + ], + }, + }, + { + "title": "Context Effector: 3 -> 4 containment", + "summary": "This score evaluates whether an activated cell should escalate into an effector response.", + "equation_lines": [ + "effector_score = 0.45 * recent_pressure", + " + 0.25 * recent_related_score", + " + 0.30 * novelty_score", + ], + "gate_lines": [ + "effector = (novelty_score > 0)", + f" and (recent_related_count >= {effector_min_related})", + f" and (effector_score >= {effector_threshold})", + ], + "notes": [ + f"recent_pressure is computed over the last {context_window}s and uses the same mixed PAMP + weighted DAMP danger model as co-stimulation.", + f"novelty_score is binary: it becomes 1 only if the matched regex has no stored memory row and no recent transition activity in the last {novelty_window}s.", + f"If the cell reaches state 4, repeated containment is still gated by an effector cooldown of {effector_cooldown}s.", + ], + "terms": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "description": "The normalized mixed danger in the recent context window for the same responsible IP.", + }, + { + "label": "recent_related_score", + "formula": f"clamp01(recent_related_count / {related_saturation})", + "description": "How much recent PAMP evidence in the context window still points to the same antigen or regex identity.", + }, + { + "label": "novelty_score", + "formula": "1 if no memory and no recent regex activity else 0", + "description": "A binary novelty gate. If the regex is already familiar, the effector path is blocked immediately.", + }, + ], + "tree": { + "label": "effector_score", + "formula": "0.45 * recent_pressure + 0.25 * recent_related_score + 0.30 * novelty_score", + "summary": f"Containment score. Threshold = {effector_threshold}", + "tooltip": "Final context score used to decide whether state 3 escalates to state 4.", + "children": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger during the most recent {context_window}s window", + "tooltip": "Computed from the recent context window immediately before the current decision.", + "children": [ + { + "label": "recent_pamp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent PAMP raw danger", + "tooltip": "Summed over recent PAMP observations in the context window.", + }, + { + "label": "recent_damp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent DAMP raw danger", + "tooltip": "Summed over recent DAMP observations in the context window.", + }, + ], + }, + { + "label": "recent_related_score", + "formula": f"clamp01(recent_related_count / {related_saturation})", + "summary": "Recent supporting PAMP count normalized to 0..1", + "tooltip": "Counts related PAMP observations in the recent context window.", + "children": [ + { + "label": "recent_related_count", + "formula": "count of related recent PAMPs", + "summary": "Same antigen value or same matched regex hash", + "tooltip": ( + f"Counted only inside the recent context window of {context_window}s." + ), + }, + { + "label": "related_pamps_saturation", + "formula": related_saturation, + "summary": "Cap for the normalized related score", + "tooltip": "The count is divided by this saturation value before clamp01 is applied.", + }, + ], + }, + { + "label": "novelty_score", + "formula": "1 if no memory and no recent activity else 0", + "summary": "Binary novelty gate", + "tooltip": "Effector requires the regex to still look new for this responsible IP.", + "children": [ + { + "label": "has_memory_for_regex", + "formula": "memory row exists for regex_hash", + "summary": "If true, novelty_score becomes 0", + "tooltip": "A stored memory for the regex marks it as familiar immediately.", + }, + { + "label": "has_recent_regex_activity", + "formula": f"transition activity within {novelty_window}s", + "summary": "If true, novelty_score becomes 0", + "tooltip": "Any recent transition for the same responsible IP and regex hash removes novelty.", + }, + ], + }, + ], + }, + }, + { + "title": "Context Memory: 3 -> 5 storage", + "summary": "This score evaluates whether an activated cell should store memory instead of escalating to containment.", + "equation_lines": [ + "memory_score = 0.60 * decrease_score", + " + 0.25 * familiarity_score", + " + 0.15 * stability_score", + ], + "gate_lines": [ + "memory = (familiarity_score > 0)", + f" and (recent_related_count >= {memory_min_related})", + f" and (trend_ratio <= {memory_ratio_max})", + f" and (memory_score >= {memory_threshold})", + ], + "notes": [ + "Memory is the cooling-down path: the same pattern is no longer novel, pressure is lower than before, and enough related evidence still supports the match.", + "trend_ratio compares the recent mixed pressure window against the previous adjacent window. Lower is better for memory.", + f"If neither effector nor memory passes, the cell stays in 3 - activated until the context wait timeout of {wait_limit}s expires.", + ], + "terms": [ + { + "label": "decrease_score", + "formula": "clamp01(1 - trend_ratio)", + "description": "Rewards situations where recent pressure is clearly lower than previous pressure.", + }, + { + "label": "trend_ratio", + "formula": "recent_pressure / max(previous_pressure, 0.01)", + "description": "Measures whether the mixed danger is falling, flat, or rising between adjacent context windows.", + }, + { + "label": "familiarity_score", + "formula": "1 - novelty_score", + "description": "Memory requires the regex to already be familiar rather than novel.", + }, + { + "label": "stability_score", + "formula": f"clamp01(recent_related_count / {memory_min_related})", + "description": "Ensures there is still enough related recent evidence to justify storing memory.", + }, + ], + "tree": { + "label": "memory_score", + "formula": "0.60 * decrease_score + 0.25 * familiarity_score + 0.15 * stability_score", + "summary": f"Memory score. Threshold = {memory_threshold}", + "tooltip": "Final context score used to decide whether state 3 transitions into state 5.", + "children": [ + { + "label": "decrease_score", + "formula": "clamp01(1 - trend_ratio)", + "summary": "Higher when recent pressure is falling", + "tooltip": "A falling trend pushes the memory score up.", + "children": [ + { + "label": "trend_ratio", + "formula": "recent_pressure / max(previous_pressure, 0.01)", + "summary": f"Must stay <= {memory_ratio_max} for memory", + "tooltip": "Compares the most recent context window against the immediately preceding one.", + "children": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger over the last {context_window}s", + "tooltip": "Same recent pressure value also used by effector_score.", + }, + { + "label": "previous_pressure", + "formula": ( + "clamp01((previous_pamp_raw + " + f"{damp_weight} * previous_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger over the previous {context_window}s window", + "tooltip": "Computed over the context window immediately before the recent one.", + }, + ], + } + ], + }, + { + "label": "familiarity_score", + "formula": "1 - novelty_score", + "summary": "Higher when the regex is already familiar", + "tooltip": "Memory is only allowed once novelty has disappeared.", + "children": [ + { + "label": "novelty_score", + "formula": "1 if no memory and no recent activity else 0", + "summary": "Same novelty gate used by the effector path", + "tooltip": "If novelty_score stays 1, familiarity_score stays 0 and memory fails.", + } + ], + }, + { + "label": "stability_score", + "formula": f"clamp01(recent_related_count / {memory_min_related})", + "summary": "Recent evidence stability", + "tooltip": "Memory still requires enough related recent PAMPs to support the pattern.", + "children": [ + { + "label": "recent_related_count", + "formula": "count of related recent PAMPs", + "summary": f"Must stay >= {memory_min_related}", + "tooltip": "Related means same antigen value or same matched regex hash in the recent context window.", + } + ], + }, + ], + }, + }, + ] + + rule_cards = [ + { + "title": "Recognition", + "rule": "0 -> 1 when a PAMP has an extracted antigen and an accepted regex match", + "description": "If a PAMP has antigens but no accepted regex match, the cell instead goes 0 -> 2 and becomes anergic.", + }, + { + "title": "Anergy Expiry", + "rule": f"2 -> 0 when anergy_ttl_seconds ({anergy_ttl}s) has elapsed", + "description": "Once the anergy TTL expires, the cell returns to mature and can be evaluated again.", + }, + { + "title": "Co-Stimulation Timeout", + "rule": f"1 -> 2 when the co-stimulation wait reaches {wait_limit}s", + "description": "The cell can keep waiting in 1 - antigen-recognized while later PAMP or DAMP evidence reevaluates the score, but only for one configured Slips time window.", + }, + { + "title": "Context Timeout", + "rule": f"3 -> 0 when the context wait reaches {wait_limit}s", + "description": "If neither effector nor memory passes before the waiting window ends, the cell falls back to 0 - mature.", + }, + { + "title": "Effector Cooldown", + "rule": f"4 -> 4 suppress repeated containment until {effector_cooldown}s passes", + "description": "The state can stay effector while repeated blocking publications are suppressed by cooldown.", + }, + { + "title": "Memory Retention", + "rule": "5 -> 5 keep the memory state on later matching evidence", + "description": "Once memory is stored for that cell, later hits retain state 5 without writing repeated memory_stored events.", + }, + ] + + decision_cards_html = "".join( + render_decision_doc_card(card) for card in decision_cards + ) + rule_cards_html = render_rule_cards(rule_cards) + return f""" +
    +
    +

    Decision Reference

    +

    Bottom-of-report explanation of how the T Cell equations and branch conditions are computed.

    +
    +

    + This section documents the exact values, thresholds, and helper rules used by the report and by the T Cell module decision logic. + Normalization uses clamp01(x) = max(0, min(1, x)). Hover or focus a node in each tree to inspect where that term comes from. +

    +
    + {decision_cards_html} +
    +
    +
    +

    Rule-Based Decisions

    +

    These branches are not weighted equations, but they still change state or suppress actions.

    +
    +
    + {rule_cards_html} +
    +
    +
    + """ + + +def render_history_details(summary: str, lines: list[str]) -> str: + details_body = escape("\n".join(lines or ["n/a"])) + return ( + f"
    {escape(summary)}" + f"
    {details_body}
    " + ) + + +def render_history_event_table(events: list[dict]) -> str: + if not events: + return '

    No history events were recorded for this T cell.

    ' + + head = "".join( + f"{escape(column)}" + for column in [ + "When", + "Source", + "Step", + "State path", + "Evidence", + "Threshold result", + "Evidence considered", + "Computation", + ] + ) + rows = [] + for event in events: + row_cells = [ + escape(event.get("wall") or "n/a"), + escape(event.get("source") or "unknown"), + escape(event.get("step") or "event"), + escape(event.get("state_path") or "n/a"), + escape(event.get("evidence_id") or "n/a"), + render_history_details( + event.get("threshold_summary") or "n/a", + event.get("threshold_lines") or [], + ), + render_history_details( + event.get("considered_summary") or "n/a", + event.get("considered_lines") or [], + ), + render_history_details( + event.get("computation_summary") or "n/a", + event.get("computation_lines") or [], + ), + ] + rows.append( + "" + + "".join(f"{cell}" for cell in row_cells) + + "" + ) + return ( + "
    " + "" + f"{head}" + f"{''.join(rows)}
    " + ) + + +def render_cell_histories(report: dict) -> str: + histories = report.get("cell_histories") or [] + if not histories: + return """ +
    +

    T Cell Histories

    +

    No T-cell histories were available for this run.

    +
    + """ + + index_rows = [ + { + "Responsible": escape(item.get("responsible_ip") or ""), + "Cell": escape(shorten(item.get("cell_key") or "", 64)), + "Current state": render_badge( + item.get("current_state") or "unknown", + item.get("current_state_class") or "state-unknown", + ), + "Life path": escape(shorten(item.get("life_path") or "", 88)), + "Events": escape(str(item.get("event_count") or 0)), + } + for item in histories + ] + index_table = render_simple_table( + ["Responsible", "Cell", "Current state", "Life path", "Events"], + index_rows, + "No T-cell history index available.", + ) + + trace_mode = (report.get("config") or {}).get("decision_trace_mode") + if trace_mode in (None, "", {}): + trace_note = ( + "Decision trace configuration was not found in metadata, so histories rely on whatever trace rows and transitions were stored." + ) + elif str(trace_mode).lower() in {"0", "off"}: + trace_note = ( + "Decision trace was off for this run, so histories can only show state transitions and any score snapshots saved with those transitions." + ) + elif str(trace_mode).lower() in {"1", "transitions"}: + trace_note = ( + "Decision trace was limited to transition events, so waiting reevaluations may be missing from the lifecycle view." + ) + else: + trace_note = ( + "Decision trace was fully enabled, so histories include both state changes and intermediate decision evaluations when available." + ) + + history_cards = [] + for index, item in enumerate(histories): + title = ( + f"{item.get('responsible_ip') or 'unknown'} | " + f"{item.get('regex_type') or 'unknown'}:{item.get('antigen_value') or ''}" + ) + meta_bits = [ + f"current={item.get('current_state') or 'unknown'}", + f"life path={item.get('life_path') or 'n/a'}", + f"first seen={item.get('first_seen') or 'n/a'}", + f"last seen={item.get('last_seen') or 'n/a'}", + f"events={item.get('event_count') or 0}", + f"transitions={item.get('transition_count') or 0}", + f"trace rows={item.get('trace_count') or 0}", + ] + table_html = render_history_event_table(item.get("events") or []) + history_cards.append( + f""" +
    + +
    +
    +

    T Cell

    +

    {escape(title)}

    +

    {escape(' | '.join(meta_bits))}

    +
    +
    + {render_badge(item.get("current_state") or "unknown", item.get("current_state_class") or "state-unknown")} +
    +
    +
    +
    +

    Cell key: {escape(item.get('cell_key') or '')}

    +

    Matched value: {escape(item.get('matched_value') or 'n/a')}

    + {table_html} +
    +
    + """ + ) + + return f""" +
    +
    +

    T Cell Histories

    +

    Chronological lifecycle view for each cell, combining stored state transitions with decision-trace computations.

    +
    +

    {escape(trace_note)}

    +
    +

    History Index

    + {index_table} +
    +
    + {''.join(history_cards)} +
    +
    + """ + + def render_html(report: dict) -> str: findings_html = "".join( f"
  • {escape(item)}
  • " for item in report.get("findings", []) @@ -1633,6 +2965,8 @@ def render_html(report: dict) -> str: TRACE_STAGE_COLORS, ) state_machine_graph = render_state_machine_graph(report) + decision_reference = render_decision_reference(report) + histories_section = render_cell_histories(report) return f""" @@ -1699,13 +3033,52 @@ def render_html(report: dict) -> str: font-size: 0.80rem; word-break: break-all; }} - .summary-grid, .panel-grid, .stats-grid {{ - display: grid; - gap: 14px; - }} - .stats-grid {{ - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - }} + .summary-grid, .panel-grid, .stats-grid {{ + display: grid; + gap: 14px; + }} + .tab-strip {{ + display: inline-flex; + gap: 8px; + margin: 16px 0 4px; + padding: 6px; + border-radius: 999px; + background: rgba(255, 253, 248, 0.82); + border: 1px solid rgba(123, 83, 44, 0.12); + box-shadow: 0 12px 26px rgba(66, 43, 17, 0.07); + position: sticky; + top: 10px; + z-index: 8; + backdrop-filter: blur(8px); + }} + .tab-button {{ + border: 0; + border-radius: 999px; + padding: 10px 16px; + background: transparent; + color: var(--muted); + font: inherit; + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; + }} + .tab-button:hover {{ + color: #7c2d12; + }} + .tab-button.is-active {{ + background: linear-gradient(180deg, rgba(180, 83, 9, 0.12), rgba(180, 83, 9, 0.18)); + color: #7c2d12; + box-shadow: inset 0 0 0 1px rgba(180, 83, 9, 0.12); + }} + .report-tab-panel {{ + display: none; + }} + .report-tab-panel.is-active {{ + display: block; + }} + .stats-grid {{ + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + }} .panel-grid {{ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-top: 14px; @@ -1941,23 +3314,300 @@ def render_html(report: dict) -> str: .footer-panel .report-table {{ min-width: 480px; }} - .footer-panel .report-table th, - .footer-panel .report-table td {{ - font-size: 0.70rem; - padding: 5px 7px; - }} - @media (max-width: 900px) {{ - body {{ font-size: 13px; }} - main {{ padding: 16px 12px 40px; }} - .panel-grid {{ grid-template-columns: 1fr; }} - .report-table {{ min-width: 680px; }} - }} - + .footer-panel .report-table th, + .footer-panel .report-table td {{ + font-size: 0.70rem; + padding: 5px 7px; + }} + .decision-reference, + .decision-doc, + .tree-block {{ + overflow: visible; + }} + .decision-reference code, + .formula-box code, + .term-formula, + .formula-node-formula {{ + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + }} + .decision-lead {{ + margin: 0 0 14px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.55; + }} + .decision-doc-grid {{ + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + }} + .decision-doc {{ + border: 1px solid rgba(123, 83, 44, 0.12); + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 253, 248, 0.96), rgba(245, 237, 224, 0.96)); + padding: 14px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); + }} + .decision-doc .panel-head {{ + align-items: flex-start; + margin-bottom: 12px; + }} + .decision-doc .panel-head h3, + .tree-block .panel-head h4 {{ + margin: 0; + }} + .equation-grid {{ + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + }} + .formula-box {{ + margin: 0; + padding: 12px; + border-radius: 14px; + border: 1px solid rgba(180, 83, 9, 0.16); + background: linear-gradient(180deg, rgba(255, 250, 240, 0.98), rgba(252, 242, 227, 0.98)); + color: #6b3f07; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); + overflow: auto; + white-space: pre-wrap; + }} + .decision-note {{ + margin: 10px 0 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.5; + }} + .term-grid, + .rule-grid {{ + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-top: 12px; + }} + .term-card, + .rule-card {{ + background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + }} + .term-formula {{ + margin: 0 0 8px; + font-size: 0.74rem; + line-height: 1.5; + color: #92400e; + overflow-wrap: anywhere; + }} + .term-body {{ + margin: 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.52; + }} + .tree-block {{ + margin-top: 14px; + padding: 12px; + border-radius: 16px; + border: 1px solid rgba(123, 83, 44, 0.12); + background: rgba(255, 253, 248, 0.74); + }} + .formula-tree {{ + overflow: auto; + padding: 96px 6px 6px; + }} + .formula-node-wrap {{ + display: flex; + flex-direction: column; + align-items: center; + min-width: max-content; + position: relative; + }} + .formula-node {{ + position: relative; + display: grid; + gap: 4px; + min-width: 180px; + max-width: 260px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(123, 83, 44, 0.16); + background: linear-gradient(180deg, rgba(255, 253, 248, 0.99), rgba(248, 239, 226, 0.98)); + box-shadow: 0 12px 24px rgba(66, 43, 17, 0.08); + outline: none; + }} + .formula-node:hover, + .formula-node:focus {{ + border-color: rgba(180, 83, 9, 0.72); + box-shadow: 0 16px 28px rgba(180, 83, 9, 0.12); + }} + .formula-node-label {{ + font-weight: 700; + font-size: 0.82rem; + color: var(--ink); + }} + .formula-node-formula {{ + font-size: 0.71rem; + line-height: 1.45; + color: #92400e; + }} + .formula-node-summary {{ + font-size: 0.74rem; + line-height: 1.4; + color: var(--muted); + }} + .formula-tooltip {{ + position: absolute; + left: 50%; + bottom: calc(100% + 12px); + transform: translateX(-50%) translateY(6px); + min-width: 230px; + max-width: 320px; + padding: 10px 12px; + border-radius: 12px; + background: #1f2937; + color: #f8fafc; + font-size: 0.72rem; + line-height: 1.45; + box-shadow: 0 18px 28px rgba(15, 23, 42, 0.28); + opacity: 0; + pointer-events: none; + transition: opacity 140ms ease, transform 140ms ease; + z-index: 25; + }} + .formula-tooltip::after {{ + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 6px; + border-style: solid; + border-color: #1f2937 transparent transparent transparent; + }} + .formula-node:hover .formula-tooltip, + .formula-node:focus .formula-tooltip, + .formula-node:focus-within .formula-tooltip {{ + opacity: 1; + transform: translateX(-50%) translateY(0); + }} + .formula-children {{ + display: flex; + justify-content: center; + gap: 16px; + align-items: flex-start; + position: relative; + padding-top: 18px; + margin-top: 10px; + }} + .formula-children::before {{ + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 18px; + background: var(--line); + }} + .formula-children.has-multiple::after {{ + content: ""; + position: absolute; + top: 0; + left: 12%; + right: 12%; + height: 2px; + background: var(--line); + }} + .formula-branch {{ + position: relative; + display: flex; + flex-direction: column; + align-items: center; + }} + .formula-branch::before {{ + content: ""; + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 18px; + background: var(--line); + }} + .history-index-panel {{ + margin-top: 14px; + }} + .history-stack {{ + display: grid; + gap: 12px; + margin-top: 14px; + }} + .history-card {{ + border: 1px solid rgba(123, 83, 44, 0.12); + border-radius: 18px; + background: rgba(255, 253, 248, 0.92); + overflow: hidden; + box-shadow: 0 14px 24px rgba(66, 43, 17, 0.06); + }} + .history-card summary {{ + list-style: none; + cursor: pointer; + padding: 14px 16px; + }} + .history-card summary::-webkit-details-marker {{ + display: none; + }} + .history-card[open] summary {{ + border-bottom: 1px solid rgba(123, 83, 44, 0.12); + background: linear-gradient(180deg, rgba(245, 237, 224, 0.96), rgba(255, 253, 248, 0.96)); + }} + .history-summary {{ + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + }} + .history-summary h3 {{ + margin: 0 0 6px; + font-size: 0.94rem; + line-height: 1.35; + }} + .history-summary-side {{ + flex-shrink: 0; + }} + .history-body {{ + padding: 14px 16px 16px; + }} + .history-table {{ + min-width: 1120px; + table-layout: auto; + }} + .history-table td {{ + min-width: 120px; + }} + @media (max-width: 900px) {{ + body {{ font-size: 13px; }} + main {{ padding: 16px 12px 40px; }} + .panel-grid {{ grid-template-columns: 1fr; }} + .report-table {{ min-width: 680px; }} + .decision-doc-grid {{ grid-template-columns: 1fr; }} + .formula-tree {{ padding-top: 104px; }} + .tab-strip {{ + width: 100%; + justify-content: stretch; + }} + .tab-button {{ + flex: 1 1 0; + text-align: center; + }} + }} +
    -
    -

    T Cell HTML Report

    +
    +

    T Cell HTML Report

    T Cell Run Report

    Static analysis of observations, signals, transitions, memories, and optional decision traces. Generated at {escape(report['generated_at'])}

    @@ -1967,11 +3617,18 @@ def render_html(report: dict) -> str:

    Database

    {escape(report['sources']['db_path'])}

    Module Log

    {escape(report['sources']['log_path'])}

    Decision Trace

    {escape(report['sources']['trace_path'])}
    -
    -
    +
    + -
    -
    +
    + + +
    + +
    + +
    +

    Quick Summary

    {render_counter_cards(report)} @@ -2030,27 +3687,55 @@ def render_html(report: dict) -> str: {trace_section}
    -
    -

    Recent Observations

    -

    These rows come from the T Cell SQLite DB, so they remain available even when module log verbosity was low. Click a column header to sort.

    - {observation_table} -
    - - - - + + +""" + + +def state_class_name(label: str) -> str: + mapping = {value: STATE_CLASS[key] for key, value in STATE_LABELS.items()} + return mapping.get(label, "state-unknown") + + +def write_report(run_output_dir: Path, output_html: Path, args: argparse.Namespace) -> Path: + report = build_report_payload( + run_output_dir, + max_observations=args.max_observations, + max_log_lines=args.max_log_lines, + max_trace_rows=args.max_trace_rows, + ) + output_html.parent.mkdir(parents=True, exist_ok=True) + output_html.write_text(render_html(report), encoding="utf-8") + return output_html + + +def main() -> int: + args = parse_args() + run_output_dir = Path(args.run_output_dir).expanduser().resolve() + output_html = ( + Path(args.out).expanduser().resolve() + if args.out + else run_output_dir / "t_cell_report.html" + ) + report_path = write_report(run_output_dir, output_html, args) + print(f"Report written to: {report_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 53557b81a34e2c9b9c9d7ecca5e96b3254b4265b Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:37 +0000 Subject: [PATCH 0352/1100] feat: add offline HTML report generator for T Cell module analysis --- docs/t_cell_module.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index af8e2a444b..1ccf735416 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -418,6 +418,41 @@ Performance note: - trace mode performs extra observation lookups and extra file writes, so it should be treated as a verification feature, not the normal default path +### Offline HTML Report + +The module includes a separate offline report generator: + +```bash +./venv/bin/python modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ +``` + +By default it writes: + +```text +/t_cell_report.html +``` + +The report is static and self-contained. It reads the T Cell SQLite DB as the +primary source, then enriches the page with `t_cell.log` and +`t_cell_trace.jsonl` when those files exist. This means: + +- it still explains the run when `log_verbosity` is `1` +- it gains richer per-evidence detail when `log_verbosity` is `2` or `3` +- it gains threshold-by-threshold explanations when decision tracing is enabled + +The page focuses on the run itself, including: + +- total `PAMP` and `DAMP` observations +- evidence type mix +- extracted antigens and matched regexes +- current cells and their states +- transition reasons and state-path counts +- memories stored so far +- observation, transition, and trace timelines +- a sortable Recent Observations table at the bottom of the page +- a compact, collapsed configuration snapshot at the very end + Color mapping: - `0 - mature` -> cyan From fc2242e679fa7ca7ee40d8e78e5755d749894962 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:43 +0000 Subject: [PATCH 0353/1100] feat: add instructions for offline HTML report generation in T Cell module --- modules/t_cell/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index 3473befe02..db0074120d 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -38,6 +38,28 @@ Artifacts: The configured trace path is always forced under the selected run output directory. - module DB: `/t_cell/t_cell.sqlite` +- offline HTML report: `/t_cell_report.html` + +## Local HTML Report + +Use the included offline report generator to build a static HTML page from a +completed or running Slips output directory: + +```bash +./venv/bin/python modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ +``` + +By default it writes: + +```text +output//t_cell_report.html +``` + +The report reads the T Cell SQLite DB first, then enriches the page with the +module log and decision trace when those files exist. That means it still gives +useful summaries when `log_verbosity` is `1` or `2`, and becomes more detailed +when verbosity `3` or decision tracing is enabled. See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, configuration, formulas, and DB schema. From 02b0a5304ca4a90011d92bb9f0e2b5e2403be268 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:53 +0000 Subject: [PATCH 0354/1100] feat: add unit tests for T Cell report generation and HTML rendering --- .../modules/t_cell/test_analyze_t_cell.py | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 tests/unit/modules/t_cell/test_analyze_t_cell.py diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py new file mode 100644 index 0000000000..cecabf2fb6 --- /dev/null +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -0,0 +1,330 @@ +# SPDX-FileCopyrightText: 2026 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +from pathlib import Path +from unittest.mock import Mock + +from modules.t_cell.analyze_t_cell import build_report_payload, render_html +from slips_files.core.database.sqlite_db.t_cell_db import TCellStorage + + +def _build_storage(run_dir: Path) -> TCellStorage: + conf = Mock() + conf.t_cell_store_dir = Mock(return_value="output/t_cell") + conf.t_cell_persistent_store_dir = Mock(return_value="") + return TCellStorage(Mock(), conf, str(run_dir), 12345) + + +def _raw_evidence( + evidence_id: str, + evidence_type: str, + signal: str, + related_profile_ip: str, + attacker_ip: str, + victim_ip: str, + description: str, +) -> dict: + return { + "evidence_type": evidence_type, + "description": description, + "attacker": { + "direction": "SRC", + "ioc_type": "IP", + "value": attacker_ip, + }, + "victim": { + "direction": "DST", + "ioc_type": "IP", + "value": victim_ip, + }, + "profile": {"ip": related_profile_ip}, + "timewindow": {"number": 1}, + "uid": [], + "timestamp": "2026/03/21 09:22:37.000000+0000", + "interface": "eno1", + "id": evidence_id, + "confidence": 1.0, + "threat_level": "HIGH", + "evidence_signal": signal, + } + + +def test_build_report_payload_and_html(tmp_path): + run_dir = tmp_path / "run-output" + (run_dir / "metadata").mkdir(parents=True) + storage = _build_storage(run_dir) + + damp_observation_id = storage.insert_observation( + { + "evidence_id": "damp-1", + "evidence_type": "HTTP_TRAFFIC", + "evidence_signal": "DAMP", + "profile_ip": "2001:db8::5", + "timewindow_number": 1, + "timestamp": "2026/03/21 09:22:37.000000+0000", + "observed_at": 1000.0, + "confidence": 0.9, + "threat_level": "medium", + "threat_level_value": 0.5, + "interface": "eno1", + "uids": ["uid-damp-1"], + "antigen_count": 2, + "antigens": [ + {"regex_type": "dns_domain", "value": "rdap.db.ripe.net"}, + {"regex_type": "uri", "value": "/ip/5.161.194.92"}, + ], + "matched_regexes": [], + "raw_evidence": _raw_evidence( + "damp-1", + "HTTP_TRAFFIC", + "DAMP", + "2001:db8::5", + "2001:db8::5", + "2001:67c:2e8:22::c100:697", + "RDAP lookup over HTTP", + ), + } + ) + + pamp_observation_id = storage.insert_observation( + { + "evidence_id": "pamp-1", + "evidence_type": "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "evidence_signal": "PAMP", + "profile_ip": "203.0.113.90", + "timewindow_number": 2, + "timestamp": "2026/03/21 09:23:37.000000+0000", + "observed_at": 2000.0, + "confidence": 1.0, + "threat_level": "high", + "threat_level_value": 0.8, + "interface": "eno1", + "uids": ["uid-pamp-1"], + "antigen_count": 1, + "antigens": [ + {"regex_type": "dns_domain", "value": "bad.example.com"} + ], + "matched_regexes": [ + { + "regex_type": "dns_domain", + "value": "bad.example.com", + "regex_hash": "regex-hash-1", + "regex": r"^bad\.example\.com$", + "created_at": 1990.0, + "specificity": 1.0, + } + ], + "raw_evidence": _raw_evidence( + "pamp-1", + "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "PAMP", + "147.32.80.37", + "203.0.113.90", + "147.32.80.37", + "Known malicious domain", + ), + } + ) + + cell_key = "203.0.113.90|dns_domain|bad.example.com" + storage.upsert_cell( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "state": 5, + "state_name": "5 - memory", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": pamp_observation_id, + "last_evidence_id": "pamp-1", + "last_transition_at": 2000.3, + "last_co_stimulation": 0.91, + "last_effector_score": 0.33, + "last_memory_score": 0.78, + "context": {"novelty_score": 0, "recent_pressure": 0.42}, + "created_at": 2000.0, + "updated_at": 2000.3, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 0, + "to_state": 1, + "reason": "antigen_match", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"specificity": 1.0}, + "created_at": 2000.1, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 1, + "to_state": 3, + "reason": "co_stimulation_threshold_met", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"value": 0.91, "threshold": 0.65}, + "created_at": 2000.2, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 3, + "to_state": 5, + "reason": "context_memory", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"memory_score": 0.78, "memory_threshold": 0.60}, + "created_at": 2000.3, + } + ) + storage.upsert_memory( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "regex_hash": "regex-hash-1", + "regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "context": {"memory_score": 0.78, "recent_pressure": 0.42}, + "created_at": 2000.3, + "updated_at": 2000.3, + } + ) + + (run_dir / "metadata" / "slips.yaml").write_text( + "\n".join( + [ + "t_cell:", + " enabled: true", + " log_verbosity: 3", + " decision_trace_mode: transitions", + " co_stimulation_threshold: 0.65", + " effector_threshold: 0.70", + " memory_threshold: 0.60", + ] + ), + encoding="utf-8", + ) + (run_dir / "t_cell.log").write_text( + "\n".join( + [ + "T Cell module ready.", + "2026/03/21 09:22:37.597262 | action=antigens_extracted | evidence=HTTP_TRAFFIC | eid=damp-1 | signal=DAMP | profile=2001:db8::5 | responsible=2001:db8::5 | target=2001:67c:2e8:22::c100:697 | antigens=dns_domain:rdap.db.ripe.net, uri:/ip/5.161.194.92", + "2026/03/21 09:22:37.607926 | action=ignored_non_pamp | evidence=HTTP_TRAFFIC | eid=damp-1 | signal=DAMP | profile=2001:db8::5 | responsible=2001:db8::5 | target=2001:67c:2e8:22::c100:697", + "2026/03/21 09:23:37.607926 | action=memory_stored | state=5 - memory | evidence=THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN | eid=pamp-1 | signal=PAMP | profile=147.32.80.37 | responsible=203.0.113.90 | target=147.32.80.37 | cell=203.0.113.90|dns_domain|bad.example.com | regex=regex-hash-1 | value=bad.example.com", + ] + ), + encoding="utf-8", + ) + (run_dir / "t_cell_trace.jsonl").write_text( + "\n".join( + [ + json.dumps( + { + "ts": "2026/03/21 09:23:37.200000+0000", + "stage": "co_stimulation", + "action": "co_stimulation_threshold_met", + "from_state": "1 - antigen-recognized", + "to_state": "3 - activated", + "responsible_ip": "203.0.113.90", + "candidate": { + "regex_type": "dns_domain", + "value": "bad.example.com", + }, + "formula": { + "value": 0.91, + "threshold": 0.65, + "components": { + "related_pamps": {"count": 1}, + }, + }, + } + ), + json.dumps( + { + "ts": "2026/03/21 09:23:37.300000+0000", + "stage": "context", + "action": "context_memory", + "from_state": "3 - activated", + "to_state": "5 - memory", + "responsible_ip": "203.0.113.90", + "candidate": { + "regex_type": "dns_domain", + "value": "bad.example.com", + }, + "formula": { + "effector_score": 0.33, + "effector_threshold": 0.70, + "memory_score": 0.78, + "memory_threshold": 0.60, + }, + } + ), + ] + ), + encoding="utf-8", + ) + + payload = build_report_payload(run_dir, max_observations=50, max_log_lines=50, max_trace_rows=50) + + assert payload["totals"]["observations"] == 2 + assert payload["totals"]["signals"] == {"DAMP": 1, "PAMP": 1} + assert payload["totals"]["transitions"] == 3 + assert payload["totals"]["memories"] == 1 + assert payload["cell_states"] == {"5 - memory": 1} + assert payload["sources"]["trace_enabled"] is True + assert payload["trace"]["total_rows"] == 2 + assert payload["recent_observations"][0]["category"] == "PAMP with regex match" + assert any( + row["category"] == "DAMP with extracted antigens" + for row in payload["recent_observations"] + ) + assert payload["top_responsible_ips"][0]["label"] == "2001:db8::5" + + html = render_html(payload) + + assert "T Cell Report" in html + assert "T Cell Run Report" in html + assert "Run Findings" in html + assert "Quick Summary" in html + assert "Decision Trace" in html + assert "Module Log Tail" not in html + assert "data-sortable-table='recent-observations'" in html + assert "Click a column header to sort." in html + assert html.index("Recent Observations") < html.index("Run configuration snapshot") + assert "co_stimulation_threshold_met" in html + assert "context_memory" in html + assert "bad.example.com" in html + assert "DAMP with extracted antigens" in html + assert "PAMP with regex match" in html + + storage.close() From 4245a7e8cc36d99601d56ae37855e9f6e7ef6d86 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:10 +0000 Subject: [PATCH 0355/1100] feat: add Mermaid state diagram for T Cell state machine and enhance report details --- docs/t_cell_module.md | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 1ccf735416..6800211579 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -93,6 +93,58 @@ The persisted states are: - `4 - effector` - `5 - memory` +Mermaid state diagram: + +```mermaid +stateDiagram-v2 + [*] --> S0 : new cell + + state "0 - mature" as S0 + state "1 - antigen-recognized" as S1 + state "2 - anergic" as S2 + state "3 - activated" as S3 + state "4 - effector" as S4 + state "5 - memory" as S5 + + S0 --> S1 : PAMP + antigen extracted\n+ accepted regex match + S0 --> S2 : PAMP + antigen extracted\n+ no regex match + S0 --> S0 : DAMP only or\nno antigen extracted + + S2 --> S0 : anergy TTL expired + + S1 --> S3 : co-stimulation >= threshold\nwithin 1 Slips TW + S1 --> S1 : re-evaluate on later evidence\nwhile below threshold + S1 --> S2 : co-stimulation timeout\nafter 1 Slips TW + + S3 --> S4 : context says novel + intense + S3 --> S5 : context says familiar + cooling down + S3 --> S3 : re-evaluate on later evidence\nwhile undecided + S3 --> S0 : context timeout\nafter 1 Slips TW + + S5 --> S5 : later matching evidence retained + S4 --> S4 : repeated hits gated by\neffector cooldown + + note right of S0 + DAMP observations are stored as danger signals. + They do not perform antigen recognition + and do not create a new cell by themselves. + end note + + note right of S1 + Co-stimulation combines: + current PAMP confidence + related PAMP count + weighted PAMP+DAMP danger + for the same responsible IP. + end note + + note right of S3 + Context uses the same mixed pressure model + to decide whether to contain now + or store memory for later. + end note +``` + The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. @@ -445,12 +497,14 @@ The page focuses on the run itself, including: - total `PAMP` and `DAMP` observations - evidence type mix +- a rendered T-cell state-machine graph with per-state and per-transition counts - extracted antigens and matched regexes - current cells and their states - transition reasons and state-path counts - memories stored so far - observation, transition, and trace timelines - a sortable Recent Observations table at the bottom of the page +- a sortable Transitions table that defaults to grouping rows by T cell - a compact, collapsed configuration snapshot at the very end Color mapping: From 79bc23460660327d89ace384d2306ec443a5ba8d Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:17 +0000 Subject: [PATCH 0356/1100] feat: enhance LLMBackend to support configurable HTTP connection pool size --- modules/llm/llm.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/llm/llm.py b/modules/llm/llm.py index 5f71e2a01a..7982c7404b 100644 --- a/modules/llm/llm.py +++ b/modules/llm/llm.py @@ -114,11 +114,12 @@ def _resolve_api_key(data: dict) -> str | None: class LLMBackend: - def __init__(self, config: LLMBackendConfig): + def __init__(self, config: LLMBackendConfig, pool_maxsize: int = 2): self.config = config self.http = urllib3.PoolManager( cert_reqs="CERT_REQUIRED", ca_certs=certifi.where(), + maxsize=max(2, int(pool_maxsize)), ) def generate(self, request: dict) -> dict: @@ -345,11 +346,14 @@ def read_configuration(self): self.failed_backends[alias] = str(exc) def _create_backend(self, config: LLMBackendConfig) -> LLMBackend: + # Keep the reusable HTTP connection pool comfortably above the + # worker concurrency so busy runs do not spam pool-discard warnings. + pool_maxsize = max(2, self.worker_threads * 2) if config.provider == "openai": - return OpenAIBackend(config) + return OpenAIBackend(config, pool_maxsize=pool_maxsize) if config.provider == "anthropic": - return AnthropicBackend(config) - return OllamaBackend(config) + return AnthropicBackend(config, pool_maxsize=pool_maxsize) + return OllamaBackend(config, pool_maxsize=pool_maxsize) @staticmethod def _empty_available_backends_registry() -> dict: From 7809887b21e15e6a6a274727cfa3b3e48c491a01 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:24 +0000 Subject: [PATCH 0357/1100] feat: add sortable transition table and state machine graph to T Cell report --- modules/t_cell/analyze_t_cell.py | 288 ++++++++++++++++++++++++++++--- 1 file changed, 261 insertions(+), 27 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index 2acf246ccc..55fb3c95e6 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -709,12 +709,22 @@ def build_report_payload( "evidence_id": transition["evidence_id"], "from_state": from_label, "to_state": to_label, + "from_state_order": transition.get("from_state", -1), + "to_state_order": transition.get("to_state", -1), "reason": transition["reason"], "matched_value": transition.get("matched_value") or "", "scores": transition.get("scores") or {}, } ) - recent_transitions.sort(key=lambda item: item["ts"], reverse=True) + recent_transitions.sort( + key=lambda item: ( + item["cell_key"].lower(), + float(item["ts"]), + int(item["from_state_order"]), + int(item["to_state_order"]), + item["evidence_id"], + ) + ) current_state_counts = Counter() recent_cells = [] @@ -995,6 +1005,67 @@ def render_sortable_observation_table(rows: list[dict]) -> str: ) +def render_sortable_transition_table(rows: list[dict]) -> str: + if not rows: + return '

    No state transitions were recorded.

    ' + + columns = [ + "When", + "Path", + "Reason", + "Responsible", + "T Cell", + "Evidence", + "Scores", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + score_summary = ", ".join( + f"{key}={value}" for key, value in sorted((row["scores"] or {}).items()) + ) or "n/a" + cells = [ + (escape(row["wall"]), row["ts"]), + ( + f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " + f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}", + f"{row['from_state_order']:02d}->{row['to_state_order']:02d}", + ), + (escape(row["reason"]), row["reason"]), + (escape(row["responsible_ip"]), row["responsible_ip"]), + (escape(shorten(row["cell_key"], 54)), row["cell_key"]), + (escape(shorten(row["evidence_id"], 20)), row["evidence_id"]), + ( + f"
    show
    {render_pretty_json(row['scores'])}
    ", + score_summary, + ), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
    " + "" + f"{head}{body}
    " + ) + + def render_svg_timeline(title: str, timeline: dict, series_order: list[str], color_map: dict[str, str]) -> str: if not timeline: return ( @@ -1062,6 +1133,189 @@ def render_svg_timeline(title: str, timeline: dict, series_order: list[str], col """ +def hex_to_rgba(hex_color: str, alpha: float) -> str: + color = hex_color.lstrip("#") + if len(color) != 6: + return f"rgba(31, 41, 55, {alpha})" + red = int(color[0:2], 16) + green = int(color[2:4], 16) + blue = int(color[4:6], 16) + return f"rgba({red}, {green}, {blue}, {alpha})" + + +def render_state_machine_graph(report: dict) -> str: + node_layout = { + 0: {"x": 40, "y": 122}, + 1: {"x": 320, "y": 44}, + 2: {"x": 320, "y": 244}, + 3: {"x": 600, "y": 122}, + 4: {"x": 880, "y": 30}, + 5: {"x": 880, "y": 214}, + } + node_width = 210 + node_height = 68 + transition_counts = { + row["label"]: row["count"] for row in report.get("transition_paths", []) + } + current_state_counts = report.get("cell_states", {}) + + edges = [ + { + "from": 0, + "to": 1, + "trigger": "regex match", + "path": "M 250 156 C 275 156, 286 120, 320 104", + "label_x": 272, + "label_y": 116, + }, + { + "from": 0, + "to": 2, + "trigger": "no regex", + "path": "M 250 156 C 275 156, 286 286, 320 278", + "label_x": 268, + "label_y": 252, + }, + { + "from": 2, + "to": 0, + "trigger": "anergy TTL", + "path": "M 320 306 C 248 338, 178 322, 146 190", + "label_x": 182, + "label_y": 330, + }, + { + "from": 1, + "to": 1, + "trigger": "wait", + "path": "M 392 44 C 350 4, 502 4, 460 44", + "label_x": 426, + "label_y": 12, + }, + { + "from": 1, + "to": 3, + "trigger": "co-stimulation", + "path": "M 530 78 L 600 156", + "label_x": 542, + "label_y": 94, + }, + { + "from": 1, + "to": 2, + "trigger": "timeout", + "path": "M 425 112 L 425 244", + "label_x": 438, + "label_y": 184, + }, + { + "from": 3, + "to": 3, + "trigger": "wait", + "path": "M 672 122 C 630 82, 782 82, 740 122", + "label_x": 706, + "label_y": 90, + }, + { + "from": 3, + "to": 4, + "trigger": "contain", + "path": "M 810 144 L 880 86", + "label_x": 828, + "label_y": 112, + }, + { + "from": 3, + "to": 5, + "trigger": "remember", + "path": "M 810 168 L 880 248", + "label_x": 824, + "label_y": 214, + }, + { + "from": 3, + "to": 0, + "trigger": "context timeout", + "path": "M 600 156 C 536 236, 286 236, 250 156", + "label_x": 430, + "label_y": 260, + }, + { + "from": 4, + "to": 4, + "trigger": "cooldown", + "path": "M 952 30 C 914 -8, 1088 -8, 1050 30", + "label_x": 1000, + "label_y": 2, + }, + { + "from": 5, + "to": 5, + "trigger": "retained", + "path": "M 952 282 C 914 320, 1088 320, 1050 282", + "label_x": 998, + "label_y": 334, + }, + ] + + node_svg = [] + for state_id, label in STATE_LABELS.items(): + node = node_layout[state_id] + color = STATE_COLORS[state_class(state_id)] + count = current_state_counts.get(label, 0) + node_svg.append( + f""" + + + {escape(label)} + current cells: {count} + + """ + ) + + edge_svg = [] + for edge in edges: + from_label = STATE_LABELS[edge["from"]] + to_label = STATE_LABELS[edge["to"]] + path_key = f"{from_label} -> {to_label}" + count = int(transition_counts.get(path_key, 0)) + active = count > 0 + stroke = STATE_COLORS[state_class(edge["to"])] + edge_svg.append( + f""" + + + + {escape(edge['trigger'])} · {count} + + + """ + ) + + return f""" +
    +
    +

    T Cell State Machine

    +

    Node badges show current cells in each state. Arrow labels show how many times each transition happened in this run.

    +
    + + + + + + + + {''.join(edge_svg)} + {''.join(node_svg)} + +
    + """ + + def render_pretty_json(value: Any) -> str: return escape(json.dumps(value, indent=2, sort_keys=True)) @@ -1116,32 +1370,8 @@ def render_html(report: dict) -> str: report["recent_observations"] ) - transition_table = render_simple_table( - [ - "When", - "Path", - "Reason", - "Responsible", - "Cell", - "Evidence", - "Scores", - ], - [ - { - "When": escape(row["wall"]), - "Path": ( - f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " - f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}" - ), - "Reason": escape(row["reason"]), - "Responsible": escape(row["responsible_ip"]), - "Cell": escape(shorten(row["cell_key"], 54)), - "Evidence": escape(shorten(row["evidence_id"], 20)), - "Scores": f"
    show
    {render_pretty_json(row['scores'])}
    ", - } - for row in report["recent_transitions"] - ], - "No state transitions were recorded.", + transition_table = render_sortable_transition_table( + report["recent_transitions"] ) cell_table = render_simple_table( @@ -1332,6 +1562,7 @@ def render_html(report: dict) -> str: ["co-stimulation", "context"], TRACE_STAGE_COLORS, ) + state_machine_graph = render_state_machine_graph(report) return f""" @@ -1668,6 +1899,8 @@ def render_html(report: dict) -> str: {trace_timeline}
    + {state_machine_graph} +

    Signals

    @@ -1685,6 +1918,7 @@ def render_html(report: dict) -> str:

    Transitions

    +

    Click a column header to sort. Default order groups rows by T cell so each cell's path stays together.

    {transition_table}
    From 8644368f3da197984c0a7f218eab212f52c71854 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:30 +0000 Subject: [PATCH 0358/1100] feat: add state machine diagram for T Cell module in README --- modules/t_cell/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index db0074120d..c8bcb53322 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -31,6 +31,31 @@ Main behavior: - containment reuses the existing `new_blocking` payload shape - all T Cell state is stored in its own SQLite DB and log file +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> S0 + + state "0 - mature" as S0 + state "1 - antigen-recognized" as S1 + state "2 - anergic" as S2 + state "3 - activated" as S3 + state "4 - effector" as S4 + state "5 - memory" as S5 + + S0 --> S1 : PAMP + antigen + regex match + S0 --> S2 : PAMP + antigen + no regex match + S0 --> S0 : DAMP only or no antigen + S2 --> S0 : anergy TTL expired + S1 --> S3 : co-stimulation threshold met + S1 --> S2 : co-stimulation timeout + S3 --> S4 : context -> contain + S3 --> S5 : context -> remember + S3 --> S0 : context timeout + S5 --> S5 : later matching evidence retained +``` + Artifacts: - module log: `output/t_cell.log` From 35f87c4adc4bbe807d145f4bf3518f97105117ed Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:35 +0000 Subject: [PATCH 0359/1100] feat: add test for LLMBackend pool size scaling with worker threads --- tests/unit/modules/llm/test_llm.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/modules/llm/test_llm.py b/tests/unit/modules/llm/test_llm.py index 6fff9eb2bb..0391b05666 100644 --- a/tests/unit/modules/llm/test_llm.py +++ b/tests/unit/modules/llm/test_llm.py @@ -277,3 +277,21 @@ def test_ollama_backend_parses_response(): assert response["usage"]["input_tokens"] == 9 assert response["usage"]["output_tokens"] == 11 assert response["usage"]["total_tokens"] == 20 + + +def test_llm_backend_pool_size_scales_with_worker_threads(): + llm = ModuleFactory().create_llm_obj() + llm.worker_threads = 3 + config = LLMBackendConfig.from_dict( + "local_qwen", + { + "provider": "ollama", + "model": "qwen2.5:3b", + "base_url": "http://127.0.0.1:11434", + }, + ) + + with patch("modules.llm.llm.urllib3.PoolManager") as mock_pool: + llm._create_backend(config) + + assert mock_pool.call_args.kwargs["maxsize"] == 6 From 5b9f5df915074547737f3e11c18eb1f719bb2502 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:43 +0000 Subject: [PATCH 0360/1100] feat: enhance report HTML output with T Cell state machine details and sortable transitions --- tests/unit/modules/t_cell/test_analyze_t_cell.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index cecabf2fb6..454a5fb29a 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -317,8 +317,14 @@ def test_build_report_payload_and_html(tmp_path): assert "Run Findings" in html assert "Quick Summary" in html assert "Decision Trace" in html + assert "T Cell State Machine" in html + assert "regex match" in html + assert "current cells: 1" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html + assert "data-sortable-table='recent-transitions'" in html + assert "data-default-sort-column='4'" in html + assert "Default order groups rows by T cell" in html assert "Click a column header to sort." in html assert html.index("Recent Observations") < html.index("Run configuration snapshot") assert "co_stimulation_threshold_met" in html From 5fd565bd065df27dcabda926ec02927f54220937 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:09 +0000 Subject: [PATCH 0361/1100] fix: clarify DAMP evidence handling in T Cell module description --- docs/evidence_signals.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md index 544898b8a0..12b9cbbca8 100644 --- a/docs/evidence_signals.md +++ b/docs/evidence_signals.md @@ -6,7 +6,9 @@ The `T Cell` module consumes this same central field and only activates its state machine for antigen recognition from `PAMP` evidence. `DAMP` evidence is still stored by the module as an observation and contributes to the danger pressure used in T-cell co-stimulation and context calculations for the same -responsible IP, but it does not create cells or perform regex matching. See +responsible IP, and each new `DAMP` also reevaluates cells that are already +waiting on that responsible IP. `DAMP` does not create cells or perform regex +matching by itself. See [T Cell Module](t_cell_module.md) for the responder details. The supported values are: From 4342d720dda85c28731301f5082b8d66bfba9cad Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:15 +0000 Subject: [PATCH 0362/1100] feat: add link to T Cell offline report generation in README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index b949d9400e..e93f6df795 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,9 @@ We appreciate your contributions and thank you for helping to improve Slips! T Cell design and configuration: [docs/t_cell_module.md](docs/t_cell_module.md) +T Cell offline report generation and interpretation: +[docs/t_cell_module.md#offline-html-report](docs/t_cell_module.md#offline-html-report) + [Code docs](https://stratospherelinuxips.readthedocs.io/en/develop/code_documentation.html ) --- From 23614cd64bda7738c245f3e5425e4b166f609210 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:28 +0000 Subject: [PATCH 0363/1100] feat: enhance T Cell module documentation with DAMP handling and waiting states --- docs/t_cell_module.md | 88 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 6800211579..ab714f10be 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -6,7 +6,8 @@ accepted RegexGenerator regex corpus, and then escalates through a small state machine until it either becomes tolerant, publishes a containment request, or stores a memory snapshot for later reuse. `DAMP` observations do not perform antigen recognition, but they do raise the danger pressure used later in -co-stimulation and context decisions. +co-stimulation and context decisions and they now trigger reevaluation of +already waiting cells for the same responsible IP. The module is started by the normal Slips module loader and is enabled by default through `t_cell.enabled: true`. @@ -23,8 +24,8 @@ modules: 4. It matches those values against accepted regexes already stored by `RegexGenerator`. 5. It stores `DAMP` observations as responsible-IP danger signals and folds - them into co-stimulation and context pressure for later `PAMP` - reevaluations. + them into co-stimulation and context pressure, and `DAMP` arrivals also + trigger reevaluation of cells that are already waiting. 6. It computes co-stimulation and context scores. 7. It either becomes tolerant, activates, requests blocking, or stores memory. @@ -93,6 +94,16 @@ The persisted states are: - `4 - effector` - `5 - memory` +States `1 - antigen-recognized` and `3 - activated` can also carry an +explicit waiting substatus in the stored cell context: + +- `1 - antigen-recognized (waiting for co-stimulation)` +- `3 - activated (waiting for context)` + +This does not create new state numbers. It is an explicit runtime marker that +the cell is still in state `1` or `3`, but is currently waiting for the next +reevaluation. + Mermaid state diagram: ```mermaid @@ -113,12 +124,12 @@ stateDiagram-v2 S2 --> S0 : anergy TTL expired S1 --> S3 : co-stimulation >= threshold\nwithin 1 Slips TW - S1 --> S1 : re-evaluate on later evidence\nwhile below threshold + S1 --> S1 : re-evaluate on later PAMP or DAMP\nwhile below threshold S1 --> S2 : co-stimulation timeout\nafter 1 Slips TW S3 --> S4 : context says novel + intense S3 --> S5 : context says familiar + cooling down - S3 --> S3 : re-evaluate on later evidence\nwhile undecided + S3 --> S3 : re-evaluate on later PAMP or DAMP\nwhile undecided S3 --> S0 : context timeout\nafter 1 Slips TW S5 --> S5 : later matching evidence retained @@ -127,7 +138,8 @@ stateDiagram-v2 note right of S0 DAMP observations are stored as danger signals. They do not perform antigen recognition - and do not create a new cell by themselves. + and do not create a new cell by themselves, + but they do re-check waiting cells. end note note right of S1 @@ -149,11 +161,13 @@ The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. 2. The module stores one observation row in its own SQLite DB. -3. If the evidence signal is not `PAMP`, the module logs `ignored_non_pamp` - and stops for that evidence after storing the observation. -4. Stored `DAMP` observations do not create or match cells, but they are kept - as danger inputs and are included in the next co-stimulation or context - evaluation for the same responsible IP. +3. If the evidence signal is `DAMP`, the module stores the observation, + reevaluates any waiting cells for the same responsible IP, logs + `damp_reverification`, and does not attempt antigen recognition from that + evidence. +4. If the evidence signal is neither `PAMP` nor `DAMP`, the module logs + `ignored_non_pamp` and stops for that evidence after storing the + observation. 5. If no structured antigen can be extracted, the module logs `no_antigen_extracted` and stops for that evidence. 6. For each antigen candidate, the module loads or creates the cell in @@ -166,11 +180,13 @@ The runtime flow is: a new `anergic_until`. 10. If a regex matches, the cell goes `0 -> 1` and stores the chosen regex metadata. -11. The module computes co-stimulation from the current `PAMP`, related - `PAMP`s, and stored `DAMP` danger pressure for the same responsible IP. +11. The module computes co-stimulation from the recognized `PAMP` + confidence, related `PAMP`s, and stored `DAMP` danger pressure for the + same responsible IP. 12. If co-stimulation crosses the configured threshold, the cell goes `1 -> 3`. 13. If co-stimulation stays below threshold, the cell can wait in - `1 - antigen-recognized` for at most one configured Slips time window. + `1 - antigen-recognized` for at most one configured Slips time window, + with the cell explicitly marked as waiting for co-stimulation. 14. If that one-time-window wait expires without enough co-stimulation, the cell goes `1 -> 2 - anergic`. 15. In state `3`, the module computes context signals from the same mixed @@ -182,6 +198,11 @@ The runtime flow is: 18. If state `3` cannot decide effector or memory within one configured Slips time window, the cell goes `3 -> 0 - mature`. +Both waiting states are reevaluated on later matching `PAMP`s and on later +`DAMP` observations for the same responsible IP. `DAMP` still does not create +or match a new cell by itself; it only re-checks cells that already exist and +are waiting. + State `4` publishes the existing `new_blocking` payload for the responsible IP when blocking support is present. If blocking or ARP poisoning modules are not running, the module can simulate the effector decision and log the exact @@ -485,6 +506,9 @@ By default it writes: /t_cell_report.html ``` +You can then open that HTML file directly in any browser. If you want a +different output filename, pass `--out `. + The report is static and self-contained. It reads the T Cell SQLite DB as the primary source, then enriches the page with `t_cell.log` and `t_cell_trace.jsonl` when those files exist. This means: @@ -493,6 +517,10 @@ primary source, then enriches the page with `t_cell.log` and - it gains richer per-evidence detail when `log_verbosity` is `2` or `3` - it gains threshold-by-threshold explanations when decision tracing is enabled +Example report screenshot from a real run: + +![T Cell HTML report overview](images/t_cell/t_cell_report_overview.png) + The page focuses on the run itself, including: - total `PAMP` and `DAMP` observations @@ -507,6 +535,38 @@ The page focuses on the run itself, including: - a sortable Transitions table that defaults to grouping rows by T cell - a compact, collapsed configuration snapshot at the very end +How to read the report: + +- **Quick Summary** and **Run Findings** tell you first whether the module saw + mostly `PAMP` or `DAMP`, whether cells were created at all, and whether the + run stalled because no supported antigen could be extracted. +- **Observation / Transition timelines** show when pressure and state changes + happened over time. This is the fastest way to see whether the module was + mostly idle, mostly collecting danger, or actively moving cells. +- **T Cell State Machine** overlays the abstract state machine with run data: + each node shows how many cells are currently in that state, and each arrow + shows how many times that transition happened in the run. +- **Signals**, **Evidence Types**, and the top-* panels show what fed the + danger model: which evidence classes dominated, which responsible IPs or + targets were involved most often, and which antigens or unmatched `PAMP` + values kept appearing. +- **Transitions** is the per-cell transition history. It is sortable and + defaults to grouping rows by T cell, so you can read one cell's path from + `0 - mature` onward without manually regrouping the table. +- **Current Cells** shows the cells that still exist now, their current state, + any explicit waiting substatus such as `waiting for co-stimulation` or + `waiting for context`, and the latest co-stimulation / effector / memory + scores that were stored on the cell. +- **Stored Memories** shows which cells have already reached + `5 - memory`, along with the saved context snapshot that will be reused + later. +- **Decision Trace** is the threshold-audit section. When enabled, it is where + you verify why a threshold passed by checking the weighted formula terms and + contributing evidence IDs. +- **Recent Observations** stays at the bottom as the raw sortable evidence + audit table. It is the best section to correlate what Slips generated with + what T Cell actually received and stored. + Color mapping: - `0 - mature` -> cyan From 0bf858c6db14ad55c90d1bd19eb2660832e058ef Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:38 +0000 Subject: [PATCH 0364/1100] feat: add waiting state handling and sortable cell table to T Cell report --- modules/t_cell/analyze_t_cell.py | 166 +++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 41 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index 55fb3c95e6..86bf52cd86 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -58,6 +58,10 @@ } SIGNAL_COLORS = {"PAMP": "#c2410c", "DAMP": "#0369a1"} TRACE_STAGE_COLORS = {"co_stimulation": "#b45309", "context": "#7c3aed"} +WAITING_LABELS = { + "co_stimulation": "waiting for co-stimulation", + "context": "waiting for context", +} def parse_args() -> argparse.Namespace: @@ -155,6 +159,19 @@ def state_class(state: int | None) -> str: return STATE_CLASS.get(state, "state-unknown") +def cell_waiting_label(cell: dict | None) -> str: + context = (cell or {}).get("context") or {} + return WAITING_LABELS.get(context.get("waiting_for"), "") + + +def display_cell_state(cell: dict) -> str: + label = state_label(cell.get("state")) + waiting_label = cell_waiting_label(cell) + if waiting_label: + return f"{label} ({waiting_label})" + return label + + def shorten(value: Any, limit: int = 96) -> str: text = str(value or "") if len(text) <= limit: @@ -738,6 +755,7 @@ def build_report_payload( "cell_key": cell["cell_key"], "responsible_ip": cell["responsible_ip"], "state": label, + "state_display": display_cell_state(cell), "state_class": state_class(cell["state"]), "regex_type": cell["regex_type"], "antigen_value": cell["antigen_value"], @@ -746,6 +764,7 @@ def build_report_payload( "last_effector_score": cell.get("last_effector_score"), "last_memory_score": cell.get("last_memory_score"), "last_evidence_id": cell.get("last_evidence_id") or "", + "waiting_label": cell_waiting_label(cell), } ) recent_cells.sort(key=lambda item: item["ts"], reverse=True) @@ -947,6 +966,90 @@ def render_simple_table(columns: list[str], rows: list[dict], empty_text: str) - ) +def render_sortable_cell_table(rows: list[dict]) -> str: + if not rows: + return '

    No cells are stored.

    ' + + columns = [ + "Updated", + "State", + "Responsible", + "T Cell", + "Antigen", + "Matched value", + "Scores", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + score_parts = [ + f"co={format_float(row['last_co_stimulation'])}" + if row["last_co_stimulation"] is not None + else "", + f"eff={format_float(row['last_effector_score'])}" + if row["last_effector_score"] is not None + else "", + f"mem={format_float(row['last_memory_score'])}" + if row["last_memory_score"] is not None + else "", + ] + score_summary = ", ".join(part for part in score_parts if part) or "n/a" + waiting_html = "" + if row["waiting_label"]: + waiting_html = ( + f"
    {escape(row['waiting_label'])}
    " + ) + cells = [ + (escape(row["wall"]), row["ts"]), + ( + "
    " + f"{render_badge(row['state'], row['state_class'])}" + f"{waiting_html}" + "
    ", + row["state"], + ), + (escape(row["responsible_ip"]), row["responsible_ip"]), + ( + f"
    {escape(shorten(row['cell_key'], 72))}
    ", + row["cell_key"], + ), + ( + f"
    {escape(row['regex_type'])}:" + f"{escape(shorten(row['antigen_value'], 52))}
    ", + f"{row['regex_type']}:{row['antigen_value']}", + ), + ( + f"
    {escape(shorten(row['matched_value'], 52))}
    ", + row["matched_value"], + ), + (escape(score_summary), score_summary), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
    " + "" + f"{head}{body}
    " + ) + + def render_sortable_observation_table(rows: list[dict]) -> str: if not rows: return '

    No observations available.

    ' @@ -1374,47 +1477,7 @@ def render_html(report: dict) -> str: report["recent_transitions"] ) - cell_table = render_simple_table( - [ - "Updated", - "State", - "Responsible", - "Cell", - "Antigen", - "Matched value", - "Scores", - ], - [ - { - "Updated": escape(row["wall"]), - "State": render_badge(row["state"], row["state_class"]), - "Responsible": escape(row["responsible_ip"]), - "Cell": escape(shorten(row["cell_key"], 56)), - "Antigen": escape(f"{row['regex_type']}:{shorten(row['antigen_value'], 40)}"), - "Matched value": escape(shorten(row["matched_value"], 48)), - "Scores": escape( - ", ".join( - part - for part in [ - f"co={format_float(row['last_co_stimulation'])}" - if row["last_co_stimulation"] is not None - else "", - f"eff={format_float(row['last_effector_score'])}" - if row["last_effector_score"] is not None - else "", - f"mem={format_float(row['last_memory_score'])}" - if row["last_memory_score"] is not None - else "", - ] - if part - ) - or "n/a" - ), - } - for row in report["recent_cells"] - ], - "No cells are stored.", - ) + cell_table = render_sortable_cell_table(report["recent_cells"]) memory_table = render_simple_table( ["Updated", "Responsible", "Cell", "Regex", "Matched value", "Context"], @@ -1755,6 +1818,26 @@ def render_html(report: dict) -> str: .report-table tr:last-child td {{ border-bottom: none; }} + .cells-table {{ + min-width: 900px; + table-layout: auto; + }} + .cell-state-stack {{ + display: grid; + gap: 4px; + min-width: 0; + align-items: start; + }} + .cell-substate {{ + color: var(--muted); + font-size: 0.68rem; + line-height: 1.2; + }} + .cell-key {{ + line-height: 1.25; + overflow-wrap: anywhere; + word-break: break-word; + }} .sort-button {{ display: inline-flex; align-items: center; @@ -1925,6 +2008,7 @@ def render_html(report: dict) -> str:

    Current Cells

    +

    Click a column header to sort. Waiting cells keep the main state badge and show the wait condition underneath.

    {cell_table}
    From d7a89ed5061ee9af909eb415e16ecbc397dea58c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:45 +0000 Subject: [PATCH 0365/1100] feat: enhance README with detailed T Cell behavior and report insights --- modules/t_cell/README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index c8bcb53322..144c9a21a4 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -18,14 +18,16 @@ Main behavior: - `evidence.profile.ip` is the related host context, while containment and T-cell ownership use the evidence's responsible IP - stored `DAMP` observations raise the danger pressure used by - co-stimulation and context for the same responsible IP + co-stimulation and context for the same responsible IP, and each new DAMP + reevaluates waiting cells on that responsible IP - optional decision tracing writes a separate JSONL audit file showing which evidence IDs contributed to threshold calculations - co-stimulation and context scores decide whether the cell becomes tolerant, activates, requests containment, or stores memory - state `1 - antigen-recognized` and state `3 - activated` can each wait for at most one configured Slips time window before timing out to `2 - anergic` - or `0 - mature` + or `0 - mature`; waiting cells are explicitly marked as + `waiting for co-stimulation` or `waiting for context` - once a cell reaches `5 - memory`, later matching evidence keeps it in memory without emitting repeated `memory_stored` actions - containment reuses the existing `new_blocking` payload shape @@ -49,9 +51,11 @@ stateDiagram-v2 S0 --> S0 : DAMP only or no antigen S2 --> S0 : anergy TTL expired S1 --> S3 : co-stimulation threshold met + S1 --> S1 : later PAMP or DAMP re-check S1 --> S2 : co-stimulation timeout S3 --> S4 : context -> contain S3 --> S5 : context -> remember + S3 --> S3 : later PAMP or DAMP re-check S3 --> S0 : context timeout S5 --> S5 : later matching evidence retained ``` @@ -81,10 +85,23 @@ By default it writes: output//t_cell_report.html ``` +Open that HTML file locally in a browser. If you want a different filename, +pass `--out `. + The report reads the T Cell SQLite DB first, then enriches the page with the module log and decision trace when those files exist. That means it still gives useful summaries when `log_verbosity` is `1` or `2`, and becomes more detailed when verbosity `3` or decision tracing is enabled. +What the report tells you: + +- whether the run was dominated by `PAMP`, `DAMP`, or both +- which evidence types, responsible IPs, targets, and antigens drove the run +- which T-cell state transitions happened and how many times +- which cells are currently waiting, activated, anergic, effector, or memory +- why thresholds were crossed when decision tracing was enabled +- which raw observations reached the T Cell module, even when log verbosity was + low + See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, configuration, formulas, and DB schema. From 6f9c95d48aadb2d23dfb6363d23d2d4a456b2874 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:51 +0000 Subject: [PATCH 0366/1100] feat: implement waiting state handling and DAMP reevaluation in T Cell module --- modules/t_cell/t_cell.py | 560 ++++++++++++++++++++++++++++++--------- 1 file changed, 441 insertions(+), 119 deletions(-) diff --git a/modules/t_cell/t_cell.py b/modules/t_cell/t_cell.py index ded0c8fc56..7b9f420a09 100644 --- a/modules/t_cell/t_cell.py +++ b/modules/t_cell/t_cell.py @@ -57,6 +57,13 @@ TRACE_MODE_OFF = 0 TRACE_MODE_TRANSITIONS = 1 TRACE_MODE_ALL = 2 +CONTEXT_REMOVE = object() +WAITING_CO_STIMULATION = "co_stimulation" +WAITING_CONTEXT = "context" +WAITING_LABELS = { + WAITING_CO_STIMULATION: "waiting for co-stimulation", + WAITING_CONTEXT: "waiting for context", +} @dataclass(frozen=True) @@ -264,6 +271,27 @@ def _process_evidence_message(self, message: dict): ) matched_regexes = [] + if evidence.evidence_signal == EvidenceSignal.DAMP: + reevaluated_count = self._reevaluate_waiting_cells( + evidence=evidence, + observation_id=observation_id, + responsible_ip=responsible_ip, + now=now, + ) + self._log_event( + action="damp_reverification", + state=None, + evidence=evidence, + metrics={"reevaluated_cells": reevaluated_count}, + details=( + "stored DAMP danger and rechecked waiting cells for this " + "responsible IP" + ), + verbosity=LOG_VERBOSITY_DECISIONS, + ) + self._prune_observations(now) + return + if evidence.evidence_signal != EvidenceSignal.PAMP: self._log_event( action="ignored_non_pamp", @@ -364,10 +392,12 @@ def _process_candidate( now, last_observation_id=observation_id, last_evidence_id=evidence.id, - context={ - "reason": "no_regex_match_after_activation", - "observation_id": observation_id, - }, + ) + self._update_cell_context( + cell, + now, + reason="no_regex_match_after_activation", + observation_id=observation_id, ) self._log_event( action="no_regex_match", @@ -408,16 +438,348 @@ def _process_candidate( now, **match_updates, ) + cell = self._remember_match_context( + cell, + now, + observation_id, + evidence.id, + match, + ) if cell["state"] == STATE_MEMORY: - self._update_cell( + self._update_cell_context( cell, now, - context={ - "reason": "memory_retained", - "observation_id": observation_id, + reason="memory_retained", + observation_id=observation_id, + matched_regex_hash=match.regex_hash, + ) + self._log_event( + action="memory_retained", + state=STATE_MEMORY, + evidence=evidence, + cell=cell, + match=match, + details=( + "memory already exists for this cell; keeping the memory " + "state without storing a new memory event" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + return match + + return self._advance_cell_with_match( + cell=cell, + evidence=evidence, + observation_id=observation_id, + candidate=candidate, + match=match, + now=now, + responsible_ip=responsible_ip, + reference_observation_id=observation_id, + ) + + def _get_or_create_cell( + self, profile_ip: str, regex_type: str, antigen_value: str, now: float + ) -> dict: + cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) + cell = self.storage.get_cell(cell_key) + if cell: + return cell + + return { + "cell_key": cell_key, + "profile_ip": profile_ip, + "regex_type": regex_type, + "antigen_value": antigen_value, + "state": STATE_MATURE, + "state_name": STATE_INFO[STATE_MATURE]["label"], + "matched_regex_hash": None, + "matched_regex": None, + "matched_value": None, + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": None, + "last_evidence_id": None, + "last_transition_at": None, + "last_co_stimulation": None, + "last_effector_score": None, + "last_memory_score": None, + "context": {}, + "created_at": now, + "updated_at": now, + } + + def _transition_cell( + self, + cell: dict, + to_state: int, + reason: str, + evidence, + observation_id: int, + now: float, + match: RegexMatch | None = None, + scores: dict | None = None, + extra_updates: dict | None = None, + ) -> dict: + from_state = cell["state"] + updates = { + "state": to_state, + "state_name": STATE_INFO[to_state]["label"], + "last_observation_id": observation_id, + "last_evidence_id": evidence.id, + "last_transition_at": now, + } + if match: + updates.update( + { "matched_regex_hash": match.regex_hash, - }, + "matched_regex": match.regex, + "matched_value": match.value, + } + ) + if extra_updates: + updates.update(extra_updates) + + cell = self._update_cell(cell, now, **updates) + self.storage.insert_transition( + { + "cell_key": cell["cell_key"], + "profile_ip": cell["profile_ip"], + "regex_type": cell["regex_type"], + "antigen_value": cell["antigen_value"], + "evidence_id": evidence.id, + "observation_id": observation_id, + "from_state": from_state, + "to_state": to_state, + "reason": reason, + "matched_regex_hash": cell.get("matched_regex_hash"), + "matched_regex": cell.get("matched_regex"), + "matched_value": cell.get("matched_value"), + "scores": scores or {}, + "created_at": now, + } + ) + self._log_event( + action=reason, + state=to_state, + evidence=evidence, + cell=cell, + match=match, + metrics=scores, + verbosity=LOG_VERBOSITY_SUMMARY, + ) + return cell + + def _update_cell(self, cell: dict, now: float, **updates) -> dict: + cell.update(updates) + cell["updated_at"] = now + self.storage.upsert_cell(cell) + return cell + + @staticmethod + def _merge_cell_context_values(cell: dict, **updates) -> dict: + merged = dict(cell.get("context") or {}) + for key, value in updates.items(): + if value is CONTEXT_REMOVE: + merged.pop(key, None) + continue + merged[key] = value + return merged + + def _update_cell_context(self, cell: dict, now: float, **updates) -> dict: + return self._update_cell( + cell, + now, + context=self._merge_cell_context_values(cell, **updates), + ) + + def _remember_match_context( + self, + cell: dict, + now: float, + observation_id: int, + evidence_id: str, + match: RegexMatch, + ) -> dict: + return self._update_cell_context( + cell, + now, + recognition_observation_id=observation_id, + recognition_evidence_id=evidence_id, + matched_regex_created_at=match.created_at, + matched_regex_specificity=match.specificity, + ) + + def _clear_waiting_context(self, cell: dict, now: float) -> dict: + return self._update_cell_context( + cell, + now, + waiting_for=CONTEXT_REMOVE, + waiting_label=CONTEXT_REMOVE, + waiting_since=CONTEXT_REMOVE, + wait_deadline=CONTEXT_REMOVE, + wait_trigger_signal=CONTEXT_REMOVE, + wait_trigger_evidence_id=CONTEXT_REMOVE, + wait_trigger_observation_id=CONTEXT_REMOVE, + ) + + def _set_waiting_context( + self, + cell: dict, + now: float, + waiting_for: str, + evidence, + observation_id: int, + ) -> dict: + context = cell.get("context") or {} + waiting_since = context.get("waiting_since") + if context.get("waiting_for") != waiting_for or waiting_since is None: + waiting_since = ( + cell.get("last_transition_at") + or cell.get("created_at") + or now + ) + try: + waiting_since = float(waiting_since) + except (TypeError, ValueError): + waiting_since = float(now) + return self._update_cell_context( + cell, + now, + waiting_for=waiting_for, + waiting_label=WAITING_LABELS.get(waiting_for, waiting_for), + waiting_since=waiting_since, + wait_deadline=waiting_since + self.state_wait_timeout_seconds, + wait_trigger_signal=str(evidence.evidence_signal), + wait_trigger_evidence_id=evidence.id, + wait_trigger_observation_id=observation_id, + ) + + def _get_reference_observation_id( + self, cell: dict, fallback_observation_id: int + ) -> int: + context = cell.get("context") or {} + candidate_id = ( + context.get("recognition_observation_id") + or cell.get("last_observation_id") + or fallback_observation_id + ) + try: + return int(candidate_id) + except (TypeError, ValueError): + return int(fallback_observation_id) + + def _build_match_from_cell(self, cell: dict) -> RegexMatch | None: + regex_hash = str(cell.get("matched_regex_hash") or "").strip() + regex = str(cell.get("matched_regex") or "").strip() + regex_type = str(cell.get("regex_type") or "").strip() + value = str( + cell.get("matched_value") or cell.get("antigen_value") or "" + ).strip() + if not (regex_hash and regex and regex_type and value): + return None + + context = cell.get("context") or {} + created_at = context.get("matched_regex_created_at") or 0.0 + try: + created_at = float(created_at) + except (TypeError, ValueError): + created_at = 0.0 + + specificity = context.get("matched_regex_specificity") + try: + specificity = float(specificity) + except (TypeError, ValueError): + specificity = measure_regex_specificity(regex) + + return RegexMatch( + regex_type=regex_type, + value=value, + regex_hash=regex_hash, + regex=regex, + created_at=created_at, + specificity=specificity, + ) + + def _reevaluate_waiting_cells( + self, + evidence, + observation_id: int, + responsible_ip: str, + now: float, + ) -> int: + waiting_cells = self.storage.get_cells_for_profile_states( + responsible_ip, + [STATE_ANTIGEN_RECOGNIZED, STATE_ACTIVATED], + ) + reevaluated = 0 + for cell in waiting_cells: + match = self._build_match_from_cell(cell) + if not match: + self._log_event( + action="waiting_cell_missing_match", + state=cell["state"], + evidence=evidence, + cell=cell, + details=( + "cannot reevaluate waiting cell because the stored " + "regex match metadata is incomplete" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + continue + + candidate = AntigenCandidate( + regex_type=cell["regex_type"], + value=cell["antigen_value"], + ) + reference_observation_id = self._get_reference_observation_id( + cell, + observation_id, + ) + self._advance_cell_with_match( + cell=cell, + evidence=evidence, + observation_id=observation_id, + candidate=candidate, + match=match, + now=now, + responsible_ip=responsible_ip, + reference_observation_id=reference_observation_id, + ) + reevaluated += 1 + return reevaluated + + def _advance_cell_with_match( + self, + cell: dict, + evidence, + observation_id: int, + candidate: AntigenCandidate, + match: RegexMatch, + now: float, + responsible_ip: str, + reference_observation_id: int, + ) -> RegexMatch: + if ( + cell.get("last_observation_id") != observation_id + or cell.get("last_evidence_id") != evidence.id + ): + cell = self._update_cell( + cell, + now, + last_observation_id=observation_id, + last_evidence_id=evidence.id, + ) + + if cell["state"] == STATE_MEMORY: + cell = self._update_cell_context( + cell, + now, + reason="memory_retained", + observation_id=observation_id, + matched_regex_hash=match.regex_hash, ) self._log_event( action="memory_retained", @@ -435,7 +797,7 @@ def _process_candidate( co_stimulation = self._compute_co_stimulation( responsible_ip, - observation_id, + reference_observation_id, candidate, match, now, @@ -444,7 +806,11 @@ def _process_candidate( cell, now, last_co_stimulation=co_stimulation["value"], - context={"co_stimulation": co_stimulation}, + ) + cell = self._update_cell_context( + cell, + now, + co_stimulation=co_stimulation, ) if cell["state"] < STATE_ACTIVATED: @@ -473,6 +839,7 @@ def _process_candidate( match=match, scores=co_stimulation, ) + cell = self._clear_waiting_context(cell, now) elif ( cell["state"] == STATE_ANTIGEN_RECOGNIZED and self._state_wait_expired(cell, now) @@ -513,8 +880,16 @@ def _process_candidate( "anergic_until": now + self.anergy_ttl_seconds, }, ) + cell = self._clear_waiting_context(cell, now) return match else: + cell = self._set_waiting_context( + cell, + now, + WAITING_CO_STIMULATION, + evidence, + observation_id, + ) self._maybe_trace_co_stimulation( action="waiting_for_co_stimulation", evidence=evidence, @@ -540,8 +915,8 @@ def _process_candidate( match=match, details=( "score below threshold; keeping the cell in " - "antigen-recognized state until more corroborating " - "PAMPs arrive" + "antigen-recognized state and reevaluating on future " + "PAMP or DAMP evidence" ), metrics={ "score": co_stimulation["value"], @@ -567,7 +942,7 @@ def _process_candidate( context = self._compute_context_signals( responsible_ip, - observation_id, + reference_observation_id, candidate, match, now, @@ -577,7 +952,12 @@ def _process_candidate( now, last_effector_score=context["effector_score"], last_memory_score=context["memory_score"], - context={"co_stimulation": co_stimulation, "context": context}, + ) + cell = self._update_cell_context( + cell, + now, + co_stimulation=co_stimulation, + context=context, ) if context["effector"]: @@ -605,6 +985,7 @@ def _process_candidate( match=match, scores=context, ) + cell = self._clear_waiting_context(cell, now) self._apply_effector( cell, evidence, @@ -640,6 +1021,7 @@ def _process_candidate( match=match, scores=context, ) + cell = self._clear_waiting_context(cell, now) self._store_memory(cell, match, context, now) self._log_event( action="memory_stored", @@ -674,7 +1056,7 @@ def _process_candidate( from_state=cell["state"], to_state=STATE_MATURE, ) - self._transition_cell( + cell = self._transition_cell( cell=cell, to_state=STATE_MATURE, reason="context_timeout", @@ -688,8 +1070,16 @@ def _process_candidate( "wait_limit": self.state_wait_timeout_seconds, }, ) + cell = self._clear_waiting_context(cell, now) return match + cell = self._set_waiting_context( + cell, + now, + WAITING_CONTEXT, + evidence, + observation_id, + ) self._maybe_trace_context( action="waiting_for_context", evidence=evidence, @@ -715,7 +1105,8 @@ def _process_candidate( match=match, details=( "context is not strong enough yet for effector or memory; " - "keeping the current state and reevaluating on future PAMPs" + "keeping the current state and reevaluating on future PAMP " + "or DAMP evidence" ), metrics={ "effector_score": context["effector_score"], @@ -737,104 +1128,6 @@ def _process_candidate( ) return match - def _get_or_create_cell( - self, profile_ip: str, regex_type: str, antigen_value: str, now: float - ) -> dict: - cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) - cell = self.storage.get_cell(cell_key) - if cell: - return cell - - return { - "cell_key": cell_key, - "profile_ip": profile_ip, - "regex_type": regex_type, - "antigen_value": antigen_value, - "state": STATE_MATURE, - "state_name": STATE_INFO[STATE_MATURE]["label"], - "matched_regex_hash": None, - "matched_regex": None, - "matched_value": None, - "anergic_until": None, - "effector_cooldown_until": None, - "last_observation_id": None, - "last_evidence_id": None, - "last_transition_at": None, - "last_co_stimulation": None, - "last_effector_score": None, - "last_memory_score": None, - "context": {}, - "created_at": now, - "updated_at": now, - } - - def _transition_cell( - self, - cell: dict, - to_state: int, - reason: str, - evidence, - observation_id: int, - now: float, - match: RegexMatch | None = None, - scores: dict | None = None, - extra_updates: dict | None = None, - ) -> dict: - from_state = cell["state"] - updates = { - "state": to_state, - "state_name": STATE_INFO[to_state]["label"], - "last_observation_id": observation_id, - "last_evidence_id": evidence.id, - "last_transition_at": now, - } - if match: - updates.update( - { - "matched_regex_hash": match.regex_hash, - "matched_regex": match.regex, - "matched_value": match.value, - } - ) - if extra_updates: - updates.update(extra_updates) - - cell = self._update_cell(cell, now, **updates) - self.storage.insert_transition( - { - "cell_key": cell["cell_key"], - "profile_ip": cell["profile_ip"], - "regex_type": cell["regex_type"], - "antigen_value": cell["antigen_value"], - "evidence_id": evidence.id, - "observation_id": observation_id, - "from_state": from_state, - "to_state": to_state, - "reason": reason, - "matched_regex_hash": cell.get("matched_regex_hash"), - "matched_regex": cell.get("matched_regex"), - "matched_value": cell.get("matched_value"), - "scores": scores or {}, - "created_at": now, - } - ) - self._log_event( - action=reason, - state=to_state, - evidence=evidence, - cell=cell, - match=match, - metrics=scores, - verbosity=LOG_VERBOSITY_SUMMARY, - ) - return cell - - def _update_cell(self, cell: dict, now: float, **updates) -> dict: - cell.update(updates) - cell["updated_at"] = now - self.storage.upsert_cell(cell) - return cell - def _compute_co_stimulation( self, profile_ip: str, @@ -877,6 +1170,8 @@ def _compute_co_stimulation( return { "value": value, "confidence": confidence, + "confidence_observation_id": current_observation.get("id"), + "confidence_evidence_id": current_observation.get("evidence_id"), "related_pamp_count": related_pamp_count, "related_pamp_score": related_pamp_score, "profile_danger_score": profile_danger_score, @@ -1059,7 +1354,13 @@ def _maybe_trace_co_stimulation( self.co_stimulation_weights["confidence"] * co_stimulation["confidence"] ), - "evidence_id": evidence.id, + "evidence_id": co_stimulation.get( + "confidence_evidence_id" + ) + or evidence.id, + "observation_id": co_stimulation.get( + "confidence_observation_id" + ), }, "related_pamps": { "count": co_stimulation["related_pamp_count"], @@ -1416,7 +1717,12 @@ def _apply_effector( cell, now, effector_cooldown_until=next_cooldown, - context={"context": context, "effector_payload": blocking_data}, + ) + self._update_cell_context( + cell, + now, + context=context, + effector_payload=blocking_data, ) if self._blocking_modules_available(): @@ -1850,8 +2156,21 @@ def _resolve_trace_file_path(self, raw_path: str) -> str: safe_parts = ["t_cell_trace.jsonl"] return os.path.join(self.output_dir, *safe_parts) - def _colorize_state(self, state: int) -> str: + @staticmethod + def _get_waiting_label(cell: dict | None) -> str: + context = (cell or {}).get("context") or {} + waiting_for = context.get("waiting_for") + return WAITING_LABELS.get(waiting_for, "") + + def _format_state_label(self, state: int, cell: dict | None = None) -> str: label = STATE_INFO[state]["label"] + waiting_label = self._get_waiting_label(cell) + if waiting_label: + return f"{label} ({waiting_label})" + return label + + def _colorize_state(self, state: int, cell: dict | None = None) -> str: + label = self._format_state_label(state, cell) if not self.log_colors: return label return f"{STATE_INFO[state]['color']}{label}{COLOR_RESET}" @@ -1874,7 +2193,7 @@ def _log_event( f"action={action}", ] if state is not None: - parts.append(f"state={self._colorize_state(state)}") + parts.append(f"state={self._colorize_state(state, cell=cell)}") if evidence: parts.append(f"evidence={evidence.evidence_type.name}") parts.append(f"eid={evidence.id}") @@ -1888,6 +2207,9 @@ def _log_event( parts.append(f"target={target_ip}") if cell: parts.append(f"cell={cell['cell_key']}") + waiting_label = self._get_waiting_label(cell) + if waiting_label: + parts.append(f"waiting={waiting_label}") if match: parts.append(f"regex={match.regex_hash}") parts.append(f"value={match.value}") From 1c66d02931fe3193fcbf0d7c151c046f6237df38 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:57 +0000 Subject: [PATCH 0367/1100] feat: add method to retrieve cells for specific profile states in TCellSQLiteDB --- .../core/database/sqlite_db/t_cell_db.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/slips_files/core/database/sqlite_db/t_cell_db.py b/slips_files/core/database/sqlite_db/t_cell_db.py index bfd40367e1..8441bf8683 100644 --- a/slips_files/core/database/sqlite_db/t_cell_db.py +++ b/slips_files/core/database/sqlite_db/t_cell_db.py @@ -285,6 +285,27 @@ def get_all_cells(self) -> list[dict]: rows = self.select("cells", order_by="updated_at DESC") or [] return [self._row_to_cell(row) for row in rows] + def get_cells_for_profile_states( + self, profile_ip: str, states: list[int] | tuple[int, ...] + ) -> list[dict]: + normalized_states = [ + int(state) for state in (states or []) if state is not None + ] + if not normalized_states: + return [] + + placeholders = ", ".join("?" for _ in normalized_states) + rows = self.select( + "cells", + condition=( + f"profile_ip = ? AND state IN ({placeholders})" + ), + params=(profile_ip, *normalized_states), + order_by="updated_at DESC, created_at DESC", + ) + rows = rows or [] + return [self._row_to_cell(row) for row in rows] + def upsert_cell(self, record: dict): self.execute( "INSERT OR REPLACE INTO cells (" @@ -568,6 +589,11 @@ def get_cell(self, cell_key: str) -> dict | None: def get_all_cells(self) -> list[dict]: return self.db.get_all_cells() + def get_cells_for_profile_states( + self, profile_ip: str, states: list[int] | tuple[int, ...] + ) -> list[dict]: + return self.db.get_cells_for_profile_states(profile_ip, states) + def upsert_cell(self, record: dict): self.db.upsert_cell(record) From 2c3fa1a490b244a91f0ba157f09d81c67d6d99ac Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:13 +0000 Subject: [PATCH 0368/1100] feat: add upsert functionality for activated cell state and update report assertions --- .../modules/t_cell/test_analyze_t_cell.py | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index 454a5fb29a..ba4812dd59 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -151,6 +151,34 @@ def test_build_report_payload_and_html(tmp_path): "updated_at": 2000.3, } ) + storage.upsert_cell( + { + "cell_key": "192.168.1.121|tls_sni|arpanet-network.com", + "profile_ip": "192.168.1.121", + "regex_type": "tls_sni", + "antigen_value": "arpanet-network.com", + "state": 3, + "state_name": "3 - activated", + "matched_regex_hash": "regex-hash-2", + "matched_regex": r"arpanet-network\.com$", + "matched_value": "arpanet-network.com", + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": pamp_observation_id, + "last_evidence_id": "pamp-1", + "last_transition_at": 2000.4, + "last_co_stimulation": 1.0, + "last_effector_score": 0.70, + "last_memory_score": 0.40, + "context": { + "waiting_for": "context", + "waiting_since": 2000.4, + "wait_deadline": 2060.4, + }, + "created_at": 2000.4, + "updated_at": 2000.4, + } + ) storage.insert_transition( { "cell_key": cell_key, @@ -300,10 +328,15 @@ def test_build_report_payload_and_html(tmp_path): assert payload["totals"]["signals"] == {"DAMP": 1, "PAMP": 1} assert payload["totals"]["transitions"] == 3 assert payload["totals"]["memories"] == 1 - assert payload["cell_states"] == {"5 - memory": 1} + assert payload["cell_states"]["5 - memory"] == 1 + assert payload["cell_states"]["3 - activated"] == 1 assert payload["sources"]["trace_enabled"] is True assert payload["trace"]["total_rows"] == 2 assert payload["recent_observations"][0]["category"] == "PAMP with regex match" + assert any( + row["waiting_label"] == "waiting for context" + for row in payload["recent_cells"] + ) assert any( row["category"] == "DAMP with extracted antigens" for row in payload["recent_observations"] @@ -319,10 +352,11 @@ def test_build_report_payload_and_html(tmp_path): assert "Decision Trace" in html assert "T Cell State Machine" in html assert "regex match" in html - assert "current cells: 1" in html + assert "current cells:" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html assert "data-sortable-table='recent-transitions'" in html + assert "data-sortable-table='recent-cells'" in html assert "data-default-sort-column='4'" in html assert "Default order groups rows by T cell" in html assert "Click a column header to sort." in html @@ -332,5 +366,7 @@ def test_build_report_payload_and_html(tmp_path): assert "bad.example.com" in html assert "DAMP with extracted antigens" in html assert "PAMP with regex match" in html + assert "waiting for context" in html + assert "3 - activated (waiting for context)" not in html storage.close() From 81f771b013b9d3159bb565a62b771e223673ded8 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:22 +0000 Subject: [PATCH 0369/1100] feat: enhance DAMP evidence handling and add tests for waiting cell re-evaluation --- tests/unit/modules/t_cell/test_t_cell.py | 130 ++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/tests/unit/modules/t_cell/test_t_cell.py b/tests/unit/modules/t_cell/test_t_cell.py index 9f39d534c7..6e14d84668 100644 --- a/tests/unit/modules/t_cell/test_t_cell.py +++ b/tests/unit/modules/t_cell/test_t_cell.py @@ -228,7 +228,7 @@ def test_extract_antigen_candidates_from_entities_and_altflows(tmp_path): assert ("certificate_cn", "cn.bad.example.com") in extracted -def test_t_cell_ignores_damp_evidence(tmp_path): +def test_t_cell_stores_damp_evidence_and_checks_waiting_cells(tmp_path): t_cell, storage = _prepare_t_cell(tmp_path) evidence = _build_evidence("damp-1", signal=EvidenceSignal.DAMP) @@ -242,7 +242,8 @@ def test_t_cell_ignores_damp_evidence(tmp_path): t_cell.db.publish.assert_not_called() with open(t_cell.log_file_path, encoding="utf-8") as log_file: log_contents = log_file.read() - assert "ignored_non_pamp" in log_contents + assert "damp_reverification" in log_contents + assert "reevaluated_cells=0" in log_contents assert "signal=DAMP" in log_contents @@ -797,7 +798,7 @@ def test_t_cell_summary_log_hides_waiting_for_co_stimulation(tmp_path): def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): - t_cell, _ = _prepare_t_cell(tmp_path, log_verbosity=2) + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) evidence = _build_evidence("pending-2", uids=["dns-1"]) t_cell.db.get_altflow_from_uid.return_value = { "type_": "dns", @@ -813,12 +814,135 @@ def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): with open(t_cell.log_file_path, encoding="utf-8") as log_file: log_contents = log_file.read() + cell = storage.get_all_cells()[0] + assert cell["context"]["waiting_for"] == "co_stimulation" assert "waiting_for_co_stimulation" in log_contents + assert "waiting=waiting for co-stimulation" in log_contents assert "score=" in log_contents assert "threshold=" in log_contents assert "related_pamps=" in log_contents +def test_t_cell_damp_reverifies_waiting_co_stimulation_cells(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + fixed_now = 14_500.0 + profile_ip = "10.0.0.80" + evidence_pamp = _build_evidence( + "damp-reverify-costim-pamp", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + evidence_damp = _build_evidence( + "damp-reverify-costim-damp", + signal=EvidenceSignal.DAMP, + profile_ip=profile_ip, + threat_level=ThreatLevel.CRITICAL, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-reverify-costim-regex" + ) + _insert_observation( + storage=storage, + evidence_id="seed-damp-1", + profile_ip=profile_ip, + antigens=[], + observed_at=fixed_now - 20, + confidence=1.0, + threat_level_value=1.0, + threat_level="critical", + evidence_signal="DAMP", + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_pamp)) + + first_cell = storage.get_all_cells()[0] + assert first_cell["state"] == STATE_ANTIGEN_RECOGNIZED + assert first_cell["context"]["waiting_for"] == "co_stimulation" + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_damp)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_ACTIVATED + assert cell["context"]["waiting_for"] == "context" + assert any( + transition["reason"] == "co_stimulation_threshold_met" + and transition["evidence_id"] == evidence_damp.id + for transition in transitions + ) + + +def test_t_cell_damp_reverifies_waiting_context_cells(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + fixed_now = 14_800.0 + profile_ip = "10.0.0.81" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence_pamp = _build_evidence( + "damp-reverify-context-pamp", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + evidence_damp = _build_evidence( + "damp-reverify-context-damp", + signal=EvidenceSignal.DAMP, + profile_ip=profile_ip, + threat_level=ThreatLevel.CRITICAL, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-reverify-context-regex" + ) + t_cell.db.get_pid_of.side_effect = ( + lambda name: 123 if name == "Blocking" else None + ) + _seed_recent_related_observations( + storage, + profile_ip, + antigen, + fixed_now, + count=5, + confidence=1.0, + threat_level_value=0.1, + age_seconds=120, + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_pamp)) + + first_cell = storage.get_all_cells()[0] + assert first_cell["state"] == STATE_ACTIVATED + assert first_cell["context"]["waiting_for"] == "context" + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_damp)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_EFFECTOR + assert "waiting_for" not in cell["context"] + assert any( + transition["reason"] == "context_effector" + and transition["evidence_id"] == evidence_damp.id + for transition in transitions + ) + assert t_cell.db.publish.call_count == 1 + + def test_t_cell_log_file_contains_color_codes(tmp_path): t_cell, _ = _prepare_t_cell(tmp_path) evidence = _build_evidence("log-1") From d594738ed874f1206dfeeeeaa53197502fc2a1dc Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:30 +0000 Subject: [PATCH 0370/1100] feat: add T Cell report overview image to documentation --- docs/images/t_cell/t_cell_report_overview.png | Bin 0 -> 617207 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/t_cell/t_cell_report_overview.png diff --git a/docs/images/t_cell/t_cell_report_overview.png b/docs/images/t_cell/t_cell_report_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..82fcbf4649af910b57f2c9b1f1feaa56d0cae31b GIT binary patch literal 617207 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

    S_UTAO{Kq(wuHbDYV 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

    """ From 43abb73701fe9862de5194c8e5f3424c3e5d5bca Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:36 +0200 Subject: [PATCH 0374/1100] feat: add script to analyze alert creation delay from Slips alerts exports --- scripts/analyze_alert_creation_delay.py | 632 ++++++++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100755 scripts/analyze_alert_creation_delay.py diff --git a/scripts/analyze_alert_creation_delay.py b/scripts/analyze_alert_creation_delay.py new file mode 100755 index 0000000000..40f0038db5 --- /dev/null +++ b/scripts/analyze_alert_creation_delay.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Analyze alert creation delay from Slips alerts exports. + +This script measures the delay between each alert's CreateTime and StartTime, +then summarizes the distribution and how it evolves over time. It supports the +newline-delimited JSON format used by alerts.json as well as plain JSON arrays. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import sys +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path + + +DEFAULT_RESOLUTIONS = ("day", "hour", "minute") +VALID_RESOLUTIONS = set(DEFAULT_RESOLUTIONS) +DELAY_BANDS = ( + ("negative", None, 0.0), + ("0s-1s", 0.0, 1.0), + ("1s-10s", 1.0, 10.0), + ("10s-60s", 10.0, 60.0), + ("1m-5m", 60.0, 300.0), + ("5m-1h", 300.0, 3600.0), + ("1h-1d", 3600.0, 86400.0), + (">=1d", 86400.0, None), +) + + +@dataclass(frozen=True) +class AlertDelayRecord: + record_number: int + alert_id: str + severity: str + create_time: str + start_time: str + delay_seconds: float + description: str + + +@dataclass(frozen=True) +class SummaryStats: + count: int + min_seconds: float + mean_seconds: float + p50_seconds: float + p90_seconds: float + p95_seconds: float + p99_seconds: float + max_seconds: float + + +@dataclass(frozen=True) +class BucketSummary: + bucket_start: str + count: int + min_seconds: float + mean_seconds: float + p50_seconds: float + p95_seconds: float + p99_seconds: float + max_seconds: float + + +def parse_args() -> argparse.Namespace: + class HelpFormatter( + argparse.ArgumentDefaultsHelpFormatter, + argparse.RawDescriptionHelpFormatter, + ): + pass + + parser = argparse.ArgumentParser( + description=( + "Analyze alert creation delay in Slips alerts exports.\n\n" + "The script reads alerts.json, computes the per-alert delay as\n" + "CreateTime - StartTime, then summarizes the overall distribution\n" + "and how that delay evolves over time by day, hour, and minute." + ), + epilog=( + "Input format:\n" + " alerts.json can be newline-delimited JSON (one alert per line)\n" + " or a regular JSON array of alert objects.\n\n" + "Outputs:\n" + " The terminal output shows overall statistics, delay bands,\n" + " the alerts with the largest delays, and trend tables.\n" + " If --output-dir is given, the script also writes CSV files for\n" + " each selected time resolution plus a summary.json file.\n\n" + "Example:\n" + " python3 scripts/analyze_alert_creation_delay.py \\\n" + " output/test-tcell-8/alerts.json \\\n" + " --output-dir output/test-tcell-8/alert_creation_delay_report" + ), + formatter_class=HelpFormatter, + ) + parser.add_argument( + "alerts_path", + help="Path to alerts.json (JSONL or JSON array).", + ) + parser.add_argument( + "--bucket-time", + choices=("create", "start"), + default="create", + help=( + "Which timestamp to use for trend buckets. Default: create " + "(group by CreateTime)." + ), + ) + parser.add_argument( + "--resolution", + action="append", + choices=sorted(VALID_RESOLUTIONS), + help=( + "Trend resolution to emit. Repeat to select a subset. " + "Default: day, hour, minute." + ), + ) + parser.add_argument( + "--output-dir", + default="", + help=( + "Optional directory where CSV trend files, top-delays CSV, and " + "summary.json will be written." + ), + ) + parser.add_argument( + "--print-limit", + type=int, + default=120, + help=( + "Print all buckets when a resolution has at most this many buckets. " + "Default: 120." + ), + ) + parser.add_argument( + "--top-buckets", + type=int, + default=10, + help=( + "When a resolution has many buckets, print this many worst buckets " + "and this many most recent buckets. Default: 10." + ), + ) + parser.add_argument( + "--top-alerts", + type=int, + default=10, + help="Show this many alerts with the largest delays. Default: 10.", + ) + parser.add_argument( + "--description-width", + type=int, + default=110, + help="Maximum description width in the top-alerts section. Default: 110.", + ) + return parser.parse_args() + + +def detect_input_format(path: Path) -> str: + with path.open(encoding="utf-8") as handle: + while True: + char = handle.read(1) + if not char: + raise ValueError(f"{path} is empty") + if char.isspace(): + continue + return "json-array" if char == "[" else "jsonl" + + +def iter_alert_records(path: Path): + input_format = detect_input_format(path) + if input_format == "json-array": + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, list): + raise ValueError(f"{path} is a JSON array file but did not contain a list") + for index, alert in enumerate(payload, start=1): + if not isinstance(alert, dict): + raise ValueError(f"Record {index} is not a JSON object") + yield input_format, index, alert + return + + with path.open(encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + stripped = line.strip() + if not stripped: + continue + try: + alert = json.loads(stripped) + except json.JSONDecodeError as exc: + raise ValueError( + f"Invalid JSON on line {line_number}: {exc.msg}" + ) from exc + if not isinstance(alert, dict): + raise ValueError(f"Line {line_number} is not a JSON object") + yield input_format, line_number, alert + + +def parse_timestamp(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +def truncate_datetime(value: datetime, resolution: str) -> datetime: + if resolution == "day": + return value.replace(hour=0, minute=0, second=0, microsecond=0) + if resolution == "hour": + return value.replace(minute=0, second=0, microsecond=0) + if resolution == "minute": + return value.replace(second=0, microsecond=0) + raise ValueError(f"Unsupported resolution: {resolution}") + + +def percentile(sorted_values: list[float], fraction: float) -> float: + if not sorted_values: + raise ValueError("percentile() requires at least one value") + if len(sorted_values) == 1: + return sorted_values[0] + position = (len(sorted_values) - 1) * fraction + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return sorted_values[lower] + lower_value = sorted_values[lower] + upper_value = sorted_values[upper] + return lower_value + (upper_value - lower_value) * (position - lower) + + +def build_summary(values: list[float]) -> SummaryStats: + if not values: + raise ValueError("No values available to summarize") + ordered = sorted(values) + return SummaryStats( + count=len(ordered), + min_seconds=ordered[0], + mean_seconds=sum(ordered) / len(ordered), + p50_seconds=percentile(ordered, 0.50), + p90_seconds=percentile(ordered, 0.90), + p95_seconds=percentile(ordered, 0.95), + p99_seconds=percentile(ordered, 0.99), + max_seconds=ordered[-1], + ) + + +def build_bucket_summaries( + bucket_values: dict[datetime, list[float]] +) -> list[BucketSummary]: + summaries: list[BucketSummary] = [] + for bucket_start, values in sorted(bucket_values.items()): + ordered = sorted(values) + summaries.append( + BucketSummary( + bucket_start=bucket_start.isoformat(), + count=len(ordered), + min_seconds=ordered[0], + mean_seconds=sum(ordered) / len(ordered), + p50_seconds=percentile(ordered, 0.50), + p95_seconds=percentile(ordered, 0.95), + p99_seconds=percentile(ordered, 0.99), + max_seconds=ordered[-1], + ) + ) + return summaries + + +def delay_band_label(delay_seconds: float) -> str: + for label, lower, upper in DELAY_BANDS: + if lower is None and delay_seconds < upper: + return label + if upper is None and delay_seconds >= lower: + return label + if lower is not None and upper is not None and lower <= delay_seconds < upper: + return label + return "unclassified" + + +def ellipsize(text: str, width: int) -> str: + if width <= 3 or len(text) <= width: + return text + return text[: width - 3] + "..." + + +def print_summary_stats(summary: SummaryStats): + print("Overall delay statistics (CreateTime - StartTime, in seconds)") + print(f" alerts: {summary.count:,}") + print(f" min_s: {summary.min_seconds:.6f}") + print(f" mean_s: {summary.mean_seconds:.6f}") + print(f" p50_s: {summary.p50_seconds:.6f}") + print(f" p90_s: {summary.p90_seconds:.6f}") + print(f" p95_s: {summary.p95_seconds:.6f}") + print(f" p99_s: {summary.p99_seconds:.6f}") + print(f" max_s: {summary.max_seconds:.6f}") + + +def print_delay_bands(band_counts: dict[str, int], total: int): + print("\nDelay bands") + for label, _, _ in DELAY_BANDS: + count = band_counts.get(label, 0) + percentage = (count / total * 100) if total else 0.0 + print(f" {label:>8}: {count:>9,} ({percentage:6.2f}%)") + + +def print_top_alerts(top_alerts: list[AlertDelayRecord], description_width: int): + if not top_alerts: + return + print("\nLargest per-alert delays") + for rank, item in enumerate(top_alerts, start=1): + description = ellipsize(item.description.replace("\n", " "), description_width) + print( + f" {rank:>2}. delay_s={item.delay_seconds:>12.6f} " + f"record={item.record_number:<8} severity={item.severity or '-':<6} " + f"id={item.alert_id or '-'}" + ) + print( + f" start={item.start_time} create={item.create_time} " + f"description={description}" + ) + + +def print_bucket_table(rows: list[BucketSummary]): + if not rows: + print(" no buckets") + return + header = ( + f"{'bucket_start':<25} {'count':>8} {'min_s':>12} {'mean_s':>12} " + f"{'p50_s':>12} {'p95_s':>12} {'p99_s':>12} {'max_s':>12}" + ) + print(header) + print("-" * len(header)) + for row in rows: + print( + f"{row.bucket_start:<25} {row.count:>8,} " + f"{row.min_seconds:>12.3f} {row.mean_seconds:>12.3f} " + f"{row.p50_seconds:>12.3f} {row.p95_seconds:>12.3f} " + f"{row.p99_seconds:>12.3f} {row.max_seconds:>12.3f}" + ) + + +def print_resolution_summary( + resolution: str, + rows: list[BucketSummary], + print_limit: int, + top_buckets: int, + csv_path: Path | None, +): + print(f"\nBy {resolution}") + if not rows: + print(" no data") + return + + first_row = rows[0] + last_row = rows[-1] + print( + f" buckets: {len(rows):,}; first={first_row.bucket_start}; " + f"last={last_row.bucket_start}" + ) + print( + f" first mean/p50/p95: {first_row.mean_seconds:.3f} / " + f"{first_row.p50_seconds:.3f} / {first_row.p95_seconds:.3f} seconds" + ) + print( + f" last mean/p50/p95: {last_row.mean_seconds:.3f} / " + f"{last_row.p50_seconds:.3f} / {last_row.p95_seconds:.3f} seconds" + ) + if csv_path is not None: + print(f" csv: {csv_path}") + + if len(rows) <= print_limit: + print_bucket_table(rows) + return + + worst_rows = sorted( + rows, + key=lambda row: (row.p95_seconds, row.max_seconds, row.mean_seconds), + reverse=True, + )[:top_buckets] + recent_rows = rows[-top_buckets:] + + print(f" {len(rows):,} buckets exceed --print-limit={print_limit}.") + print(f" Worst {len(worst_rows)} buckets by p95_s") + print_bucket_table(sorted(worst_rows, key=lambda row: row.bucket_start)) + print(f"\n Most recent {len(recent_rows)} buckets") + print_bucket_table(recent_rows) + + +def write_bucket_csv(path: Path, rows: list[BucketSummary]): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "bucket_start", + "count", + "min_s", + "mean_s", + "p50_s", + "p95_s", + "p99_s", + "max_s", + ] + ) + for row in rows: + writer.writerow( + [ + row.bucket_start, + row.count, + f"{row.min_seconds:.6f}", + f"{row.mean_seconds:.6f}", + f"{row.p50_seconds:.6f}", + f"{row.p95_seconds:.6f}", + f"{row.p99_seconds:.6f}", + f"{row.max_seconds:.6f}", + ] + ) + + +def write_top_alerts_csv(path: Path, rows: list[AlertDelayRecord]): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "record_number", + "alert_id", + "severity", + "create_time", + "start_time", + "delay_s", + "description", + ] + ) + for row in rows: + writer.writerow( + [ + row.record_number, + row.alert_id, + row.severity, + row.create_time, + row.start_time, + f"{row.delay_seconds:.6f}", + row.description, + ] + ) + + +def ensure_output_dir(output_dir: str) -> Path | None: + if not output_dir: + return None + path = Path(output_dir).expanduser().resolve() + path.mkdir(parents=True, exist_ok=True) + return path + + +def main() -> int: + args = parse_args() + alerts_path = Path(args.alerts_path).expanduser().resolve() + if not alerts_path.exists(): + print(f"alerts file not found: {alerts_path}", file=sys.stderr) + return 1 + + resolutions = tuple(args.resolution or DEFAULT_RESOLUTIONS) + output_dir = ensure_output_dir(args.output_dir) + + overall_delays: list[float] = [] + bucket_values = { + resolution: defaultdict(list) for resolution in resolutions + } + band_counts: dict[str, int] = defaultdict(int) + top_delay_records: list[AlertDelayRecord] = [] + skipped_missing_timestamps = 0 + skipped_invalid_timestamps = 0 + negative_count = 0 + zero_count = 0 + trend_min: datetime | None = None + trend_max: datetime | None = None + input_format: str | None = None + + for current_format, record_number, alert in iter_alert_records(alerts_path): + input_format = current_format + create_time_raw = alert.get("CreateTime") + start_time_raw = alert.get("StartTime") + if not create_time_raw or not start_time_raw: + skipped_missing_timestamps += 1 + continue + + try: + create_time = parse_timestamp(create_time_raw) + start_time = parse_timestamp(start_time_raw) + except ValueError: + skipped_invalid_timestamps += 1 + continue + + delay_seconds = (create_time - start_time).total_seconds() + overall_delays.append(delay_seconds) + band_counts[delay_band_label(delay_seconds)] += 1 + if delay_seconds < 0: + negative_count += 1 + elif delay_seconds == 0: + zero_count += 1 + + top_delay_records.append( + AlertDelayRecord( + record_number=record_number, + alert_id=str(alert.get("ID") or ""), + severity=str(alert.get("Severity") or ""), + create_time=create_time_raw, + start_time=start_time_raw, + delay_seconds=delay_seconds, + description=str(alert.get("Description") or ""), + ) + ) + + trend_time = create_time if args.bucket_time == "create" else start_time + if trend_min is None or trend_time < trend_min: + trend_min = trend_time + if trend_max is None or trend_time > trend_max: + trend_max = trend_time + for resolution in resolutions: + bucket_values[resolution][ + truncate_datetime(trend_time, resolution) + ].append(delay_seconds) + + if not overall_delays: + print( + ( + "No alerts with valid CreateTime and StartTime were found in " + f"{alerts_path}" + ), + file=sys.stderr, + ) + return 1 + + overall_summary = build_summary(overall_delays) + top_delay_records = sorted( + top_delay_records, + key=lambda item: item.delay_seconds, + reverse=True, + )[: args.top_alerts] + bucket_summaries = { + resolution: build_bucket_summaries(bucket_values[resolution]) + for resolution in resolutions + } + + csv_paths: dict[str, str] = {} + if output_dir is not None: + for resolution in resolutions: + csv_path = output_dir / f"alert_creation_delay_by_{resolution}.csv" + write_bucket_csv(csv_path, bucket_summaries[resolution]) + csv_paths[resolution] = str(csv_path) + + top_alerts_csv = output_dir / "alert_creation_delay_top_alerts.csv" + write_top_alerts_csv(top_alerts_csv, top_delay_records) + csv_paths["top_alerts"] = str(top_alerts_csv) + + summary_json = output_dir / "summary.json" + summary_payload = { + "alerts_path": str(alerts_path), + "input_format": input_format, + "bucket_time": args.bucket_time, + "resolutions": list(resolutions), + "processed_alerts": overall_summary.count, + "skipped_missing_timestamps": skipped_missing_timestamps, + "skipped_invalid_timestamps": skipped_invalid_timestamps, + "negative_delays": negative_count, + "zero_delays": zero_count, + "trend_start": trend_min.isoformat() if trend_min else None, + "trend_end": trend_max.isoformat() if trend_max else None, + "overall_delay_seconds": asdict(overall_summary), + "delay_bands": [ + { + "label": label, + "count": band_counts.get(label, 0), + "percentage": ( + band_counts.get(label, 0) / overall_summary.count * 100 + ), + } + for label, _, _ in DELAY_BANDS + ], + "top_delays": [asdict(item) for item in top_delay_records], + "csv_outputs": csv_paths, + "bucket_counts": { + resolution: len(bucket_summaries[resolution]) + for resolution in resolutions + }, + } + with summary_json.open("w", encoding="utf-8") as handle: + json.dump(summary_payload, handle, indent=2) + handle.write("\n") + csv_paths["summary_json"] = str(summary_json) + + print(f"Input: {alerts_path}") + print(f"Input format: {input_format}") + print(f"Trend bucket timestamp: {args.bucket_time} time") + print( + f"Valid alerts: {overall_summary.count:,}; skipped missing timestamps: " + f"{skipped_missing_timestamps:,}; skipped invalid timestamps: " + f"{skipped_invalid_timestamps:,}" + ) + if trend_min is not None and trend_max is not None: + print(f"Trend range: {trend_min.isoformat()} -> {trend_max.isoformat()}") + print( + f"Negative delays: {negative_count:,}; zero delays: {zero_count:,}" + ) + print_summary_stats(overall_summary) + print_delay_bands(band_counts, overall_summary.count) + print_top_alerts(top_delay_records, args.description_width) + + for resolution in resolutions: + csv_path = Path(csv_paths[resolution]) if resolution in csv_paths else None + print_resolution_summary( + resolution=resolution, + rows=bucket_summaries[resolution], + print_limit=args.print_limit, + top_buckets=args.top_buckets, + csv_path=csv_path, + ) + + if output_dir is not None: + print(f"\nArtifacts written to: {output_dir}") + print(f"Summary JSON: {csv_paths['summary_json']}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b2ec4b99fc7dd3107d1aea5455f55629402e7c85 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:45 +0200 Subject: [PATCH 0375/1100] feat: add regex auditing and pruning script for benign threshold management --- scripts/regex_prune_benign_threshold.py | 649 ++++++++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100755 scripts/regex_prune_benign_threshold.py diff --git a/scripts/regex_prune_benign_threshold.py b/scripts/regex_prune_benign_threshold.py new file mode 100755 index 0000000000..fe14cec516 --- /dev/null +++ b/scripts/regex_prune_benign_threshold.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Audit and optionally prune accepted regexes that exceed the benign threshold. + +This is meant for persistent regex stores where the benign corpus may have +grown over time. A regex accepted earlier can later become too strong against +the current benign corpus even though it passed at generation time. +""" + +from __future__ import annotations + +import argparse +import json +import re +import signal +import shutil +import sqlite3 +import sys +import time +import warnings +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.core.database.sqlite_db.regex_generator_db import REGEX_TYPES +from modules.regex_generator.match_strength import ( + compute_match_strength, + measure_regex_specificity, +) + + +@dataclass +class RegexAuditResult: + id: int + regex_type: str + regex: str + regex_hash: str + created_at: float + strongest_benign_score: float + strongest_benign_value: str + + +class _NullTimeout: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, exc_tb): + return False + + +class _SignalTimeout: + def __init__(self, timeout_seconds: float): + self.timeout_seconds = timeout_seconds + self._previous_handler = None + + def __enter__(self): + self._previous_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, self._handle_timeout) + signal.setitimer(signal.ITIMER_REAL, self.timeout_seconds) + return None + + def __exit__(self, exc_type, exc, exc_tb): + signal.setitimer(signal.ITIMER_REAL, 0) + if self._previous_handler is not None: + signal.signal(signal.SIGALRM, self._previous_handler) + return False + + @staticmethod + def _handle_timeout(signum, frame): + raise TimeoutError("regex benign scan timed out") + + +def timeout_context(timeout_seconds: float): + if timeout_seconds <= 0: + return _NullTimeout() + return _SignalTimeout(timeout_seconds) + + +class AuditProgressTracker: + BAR_WIDTH = 24 + + def __init__(self, total_regexes: int, totals_by_type: dict[str, int]): + self.total_regexes = max(1, total_regexes) + self.totals_by_type = dict(totals_by_type) + self.done_regexes = 0 + self.done_by_type = {regex_type: 0 for regex_type in totals_by_type} + self.current_type = "-" + self.comparisons_done = 0 + self.flagged_done = 0 + self.timed_out_done = 0 + self.started_at = time.monotonic() + self.last_render_at = 0.0 + self.enabled = sys.stderr.isatty() + + def start(self): + if not self.enabled: + return + print( + ( + "Auditing accepted regexes against the current benign corpus " + f"({self.total_regexes} regexes)" + ), + file=sys.stderr, + flush=True, + ) + self._render(force=True) + + def advance( + self, + regex_type: str, + comparisons: int, + flagged_increment: int = 0, + timed_out_increment: int = 0, + ): + self.done_regexes += 1 + self.current_type = regex_type + self.comparisons_done += comparisons + self.flagged_done += flagged_increment + self.timed_out_done += timed_out_increment + self.done_by_type[regex_type] = self.done_by_type.get(regex_type, 0) + 1 + self._render() + + def finish(self): + if not self.enabled: + return + self._render(force=True, done=True) + print(file=sys.stderr, flush=True) + + def _render(self, force: bool = False, done: bool = False): + if not self.enabled: + return + + now = time.monotonic() + if not force and not done and now - self.last_render_at < 0.1: + return + self.last_render_at = now + + ratio = min(1.0, self.done_regexes / self.total_regexes) + filled = int(ratio * self.BAR_WIDTH) + bar = "[" + ("=" * filled) + ("." * (self.BAR_WIDTH - filled)) + "]" + elapsed = max(0.001, now - self.started_at) + if done or ratio >= 1.0: + eta = 0.0 + else: + eta = (elapsed / max(ratio, 1e-9)) - elapsed + + type_done = self.done_by_type.get(self.current_type, 0) + type_total = self.totals_by_type.get(self.current_type, 0) + status = ( + "\r" + f"{bar} {ratio * 100:6.2f}% " + f"| regex {self.done_regexes}/{self.total_regexes} " + f"| type {self.current_type} {type_done}/{type_total} " + f"| flagged {self.flagged_done} " + f"| timed out {self.timed_out_done} " + f"| cmp {self.comparisons_done:,} " + f"| ETA {self._format_duration(eta)}" + ) + print(status, end="", file=sys.stderr, flush=True) + + @staticmethod + def _format_duration(seconds: float) -> str: + total_seconds = max(0, int(seconds)) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Audit accepted regexes against the current benign corpus and " + "optionally delete those whose strongest benign match meets or " + "exceeds the configured threshold." + ) + ) + parser.add_argument( + "--run-output-dir", + default="", + help=( + "Slips run output directory containing regex_generator/*.sqlite, " + "or a direct regex store directory containing generated_regexes.sqlite " + "and benign_corpus.sqlite." + ), + ) + parser.add_argument( + "--regex-db", + default="", + help="Path to generated_regexes.sqlite. Overrides --run-output-dir.", + ) + parser.add_argument( + "--benign-db", + default="", + help="Path to benign_corpus.sqlite. Overrides --run-output-dir.", + ) + parser.add_argument( + "--threshold", + type=float, + default=None, + help=( + "Benign match-strength threshold. Defaults to " + "regex_generator.benign_match_strength_threshold from config, " + "or 75 if unavailable." + ), + ) + parser.add_argument( + "--regex-type", + action="append", + choices=sorted(REGEX_TYPES), + help="Limit the audit to one or more regex types.", + ) + parser.add_argument( + "--match-timeout-seconds", + type=float, + default=None, + help=( + "Maximum wall-clock seconds allowed for one accepted regex to scan " + "the benign corpus for its regex type. Timed-out regexes are " + "skipped and never deleted. Defaults to " + "regex_generator.regex_validation_timeout_seconds from config, " + "or 2.0 if unavailable. Set 0 to disable." + ), + ) + parser.add_argument( + "--limit", + type=int, + default=20, + help="Maximum number of example rows to print per regex type.", + ) + parser.add_argument( + "--output-json", + default="", + help="Optional JSON output path for the audit summary.", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete accepted regex rows that exceed the threshold.", + ) + parser.add_argument( + "--no-backup", + action="store_true", + help="Do not create a backup copy of generated_regexes.sqlite before deletion.", + ) + parser.add_argument( + "--vacuum", + action="store_true", + help="Run VACUUM on generated_regexes.sqlite after deletion.", + ) + return parser.parse_args() + + +def default_threshold() -> float: + try: + return float( + ConfigParser().regex_generator_benign_match_strength_threshold() + ) + except Exception: + return 75.0 + + +def default_match_timeout() -> float: + try: + return float(ConfigParser().regex_generator_regex_validation_timeout_seconds()) + except Exception: + return 2.0 + + +def resolve_paths(args: argparse.Namespace) -> tuple[Path, Path]: + if args.regex_db and args.benign_db: + return Path(args.regex_db).expanduser(), Path(args.benign_db).expanduser() + + if not args.run_output_dir: + raise SystemExit( + "Provide either --regex-db and --benign-db, or --run-output-dir." + ) + + base = Path(args.run_output_dir).expanduser() + direct_regex = base / "generated_regexes.sqlite" + direct_benign = base / "benign_corpus.sqlite" + nested_regex = base / "regex_generator" / "generated_regexes.sqlite" + nested_benign = base / "regex_generator" / "benign_corpus.sqlite" + + if direct_regex.exists() and direct_benign.exists(): + return direct_regex, direct_benign + if nested_regex.exists() and nested_benign.exists(): + return nested_regex, nested_benign + + raise SystemExit( + "Could not find regex DBs. Checked:\n" + f"- {direct_regex} and {direct_benign}\n" + f"- {nested_regex} and {nested_benign}" + ) + + +def load_benign_values(benign_db_path: Path) -> dict[str, list[str]]: + benign_values = {regex_type: [] for regex_type in REGEX_TYPES} + with sqlite3.connect(benign_db_path) as conn: + rows = conn.execute( + "SELECT regex_type, value FROM benign_strings ORDER BY id ASC" + ) + for regex_type, value in rows: + benign_values.setdefault(regex_type, []).append(str(value or "")) + return benign_values + + +def load_accepted_regexes( + regex_db_path: Path, regex_types: set[str] +) -> dict[str, list[dict]]: + accepted = defaultdict(list) + with sqlite3.connect(regex_db_path) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT id, regex_type, regex, regex_hash, created_at + FROM generated_regexes + WHERE status = 'accepted' + ORDER BY created_at ASC, id ASC + """ + ).fetchall() + for row in rows: + regex_type = row["regex_type"] + if regex_type not in regex_types: + continue + accepted[regex_type].append(dict(row)) + return accepted + + +def audit_regex_type( + regex_rows: list[dict], + benign_values: list[str], + threshold: float, + match_timeout_seconds: float, + progress: AuditProgressTracker | None = None, +) -> tuple[list[RegexAuditResult], list[dict]]: + flagged = [] + timed_out = [] + for row in regex_rows: + comparisons_checked = 0 + flagged_increment = 0 + timed_out_increment = 0 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + compiled = re.compile(row["regex"]) + except re.error: + if progress is not None: + progress.advance( + row["regex_type"], + comparisons=comparisons_checked, + flagged_increment=flagged_increment, + timed_out_increment=timed_out_increment, + ) + continue + + regex_features = measure_regex_specificity(row["regex"]) + best_score = 0.0 + best_value = "" + try: + with timeout_context(match_timeout_seconds): + for value in benign_values: + comparisons_checked += 1 + score = compute_match_strength(compiled, value, regex_features) + if score > best_score: + best_score = score + best_value = value + if best_score >= threshold: + flagged_increment = 1 + flagged.append( + RegexAuditResult( + id=int(row["id"]), + regex_type=row["regex_type"], + regex=row["regex"], + regex_hash=row["regex_hash"], + created_at=float(row["created_at"]), + strongest_benign_score=best_score, + strongest_benign_value=best_value, + ) + ) + break + except TimeoutError: + timed_out_increment = 1 + timed_out.append( + { + "id": int(row["id"]), + "regex_type": row["regex_type"], + "regex": row["regex"], + "regex_hash": row["regex_hash"], + "created_at": float(row["created_at"]), + "comparisons_checked": comparisons_checked, + } + ) + if progress is not None: + progress.advance( + row["regex_type"], + comparisons=comparisons_checked, + flagged_increment=flagged_increment, + timed_out_increment=timed_out_increment, + ) + flagged.sort( + key=lambda item: ( + item.regex_type, + item.strongest_benign_score, + item.created_at, + item.id, + ), + reverse=True, + ) + timed_out.sort( + key=lambda item: ( + item["regex_type"], + item["created_at"], + item["id"], + ), + reverse=True, + ) + return flagged, timed_out + + +def backup_regex_db(regex_db_path: Path) -> Path: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_path = regex_db_path.with_suffix(regex_db_path.suffix + f".bak.{timestamp}") + shutil.copy2(regex_db_path, backup_path) + return backup_path + + +def delete_flagged_regexes( + regex_db_path: Path, flagged_results: list[RegexAuditResult], vacuum: bool +) -> int: + ids = [result.id for result in flagged_results] + if not ids: + return 0 + + placeholders = ",".join("?" for _ in ids) + with sqlite3.connect(regex_db_path) as conn: + cursor = conn.execute( + f"DELETE FROM generated_regexes WHERE id IN ({placeholders})", + ids, + ) + deleted = int(cursor.rowcount or 0) + conn.commit() + if vacuum: + conn.execute("VACUUM") + return deleted + + +def build_summary( + regex_db_path: Path, + benign_db_path: Path, + threshold: float, + regex_types: list[str], + accepted_by_type: dict[str, list[dict]], + flagged_by_type: dict[str, list[RegexAuditResult]], + timed_out_by_type: dict[str, list[dict]], + limit: int, + deleted: int, + backup_path: Path | None, + match_timeout_seconds: float, +) -> dict: + summary_types = {} + for regex_type in regex_types: + flagged_rows = flagged_by_type.get(regex_type, []) + timed_out_rows = timed_out_by_type.get(regex_type, []) + summary_types[regex_type] = { + "accepted_count": len(accepted_by_type.get(regex_type, [])), + "flagged_count": len(flagged_rows), + "timed_out_count": len(timed_out_rows), + "examples": [ + { + **asdict(result), + "created_at_iso": datetime.fromtimestamp( + result.created_at, tz=timezone.utc + ).isoformat(), + } + for result in flagged_rows[:limit] + ], + "timed_out_examples": [ + { + **row, + "created_at_iso": datetime.fromtimestamp( + row["created_at"], tz=timezone.utc + ).isoformat(), + } + for row in timed_out_rows[:limit] + ], + } + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "regex_db_path": str(regex_db_path), + "benign_db_path": str(benign_db_path), + "threshold": threshold, + "match_timeout_seconds": match_timeout_seconds, + "regex_types": regex_types, + "deleted_count": deleted, + "backup_path": str(backup_path) if backup_path else "", + "totals": { + "accepted_count": sum( + len(accepted_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + "flagged_count": sum( + len(flagged_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + "timed_out_count": sum( + len(timed_out_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + }, + "types": summary_types, + } + + +def print_summary(summary: dict, delete_mode: bool): + action = "Deleted" if delete_mode else "Flagged" + print( + f"Threshold: {summary['threshold']:.2f}\n" + f"Match timeout per regex: {summary['match_timeout_seconds']:.2f}s\n" + f"Regex DB: {summary['regex_db_path']}\n" + f"Benign DB: {summary['benign_db_path']}\n" + f"Accepted rows scanned: {summary['totals']['accepted_count']}\n" + f"{action} rows: {summary['totals']['flagged_count']}\n" + f"Timed-out rows skipped: {summary['totals']['timed_out_count']}" + ) + print( + "Accepted means rows currently stored in generated_regexes.sqlite " + "with status='accepted'." + ) + if delete_mode: + print( + "Deleted means accepted rows whose strongest benign match score " + "met or exceeded the threshold and were removed." + ) + else: + print( + "Flagged means accepted rows whose strongest benign match score " + "meets or exceeds the threshold against the current benign corpus." + ) + if summary.get("backup_path"): + print(f"Backup: {summary['backup_path']}") + + for regex_type in summary["regex_types"]: + row = summary["types"][regex_type] + print( + f"\n[{regex_type}] accepted={row['accepted_count']} " + f"flagged={row['flagged_count']} " + f"timed_out={row['timed_out_count']}" + ) + for example in row["examples"]: + print( + " " + f"score={example['strongest_benign_score']:.2f} " + f"value={example['strongest_benign_value']} " + f"created_at={example['created_at_iso']} " + f"regex={example['regex']}" + ) + for example in row["timed_out_examples"]: + print( + " " + "timed_out " + f"after_cmp={example['comparisons_checked']} " + f"created_at={example['created_at_iso']} " + f"regex={example['regex']}" + ) + + +def main(): + args = parse_args() + regex_db_path, benign_db_path = resolve_paths(args) + threshold = ( + float(args.threshold) if args.threshold is not None else default_threshold() + ) + match_timeout_seconds = ( + float(args.match_timeout_seconds) + if args.match_timeout_seconds is not None + else default_match_timeout() + ) + regex_types = sorted(set(args.regex_type or REGEX_TYPES)) + + benign_values = load_benign_values(benign_db_path) + accepted_by_type = load_accepted_regexes(regex_db_path, set(regex_types)) + progress = AuditProgressTracker( + total_regexes=sum( + len(accepted_by_type.get(regex_type, [])) for regex_type in regex_types + ), + totals_by_type={ + regex_type: len(accepted_by_type.get(regex_type, [])) + for regex_type in regex_types + }, + ) + progress.start() + flagged_by_type = {} + timed_out_by_type = {} + for regex_type in regex_types: + flagged_rows, timed_out_rows = audit_regex_type( + accepted_by_type.get(regex_type, []), + benign_values.get(regex_type, []), + threshold, + match_timeout_seconds, + progress=progress, + ) + flagged_by_type[regex_type] = flagged_rows + timed_out_by_type[regex_type] = timed_out_rows + progress.finish() + + backup_path = None + deleted = 0 + flagged_results = [ + result + for regex_type in regex_types + for result in flagged_by_type.get(regex_type, []) + ] + if args.delete and flagged_results: + if not args.no_backup: + backup_path = backup_regex_db(regex_db_path) + deleted = delete_flagged_regexes(regex_db_path, flagged_results, args.vacuum) + + summary = build_summary( + regex_db_path=regex_db_path, + benign_db_path=benign_db_path, + threshold=threshold, + regex_types=regex_types, + accepted_by_type=accepted_by_type, + flagged_by_type=flagged_by_type, + timed_out_by_type=timed_out_by_type, + limit=max(0, args.limit), + deleted=deleted, + backup_path=backup_path, + match_timeout_seconds=match_timeout_seconds, + ) + print_summary(summary, delete_mode=args.delete) + + if args.output_json: + output_path = Path(args.output_json).expanduser() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() From 95d038ab30fcc688dc7714694120ccfabe9ca8b4 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:52 +0200 Subject: [PATCH 0376/1100] feat: enhance report HTML assertions for regex and co-stimulation states --- tests/unit/modules/t_cell/test_analyze_t_cell.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index ba4812dd59..6767cd269b 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -351,7 +351,11 @@ def test_build_report_payload_and_html(tmp_path): assert "Quick Summary" in html assert "Decision Trace" in html assert "T Cell State Machine" in html - assert "regex match" in html + assert "accepted regex match" in html + assert "no accepted regex match" in html + assert "stays mature" in html + assert "co-stimulation below threshold" in html + assert "no co-stimulation timeout" in html assert "current cells:" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html From 6eaf041b1a4f1f0548781ebfe59ea0ddd8903b88 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sun, 19 Apr 2026 09:56:07 +0200 Subject: [PATCH 0377/1100] feat: enable decision trace mode for T Cell responder module --- config/slips.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/slips.yaml b/config/slips.yaml index 30ce9fb459..8b1f004359 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -373,7 +373,7 @@ t_cell: # off = disabled # transitions = write detailed traces only when a state transition happens # all = also trace waiting evaluations - decision_trace_mode: off + decision_trace_mode: on # Separate trace file used only when decision_trace_mode is not off. # This path is always resolved inside the selected output directory for the From fd99441e54d31948ba428adbdcd939af684f14ae Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sun, 19 Apr 2026 09:56:25 +0200 Subject: [PATCH 0378/1100] Implement feature X to enhance user experience and fix bug Y in module Z --- modules/t_cell/analyze_t_cell.py | 1779 +++++++++++++++++++++++++++++- 1 file changed, 1732 insertions(+), 47 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index d0facc12e5..9ba9a80038 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -62,6 +62,29 @@ "co_stimulation": "waiting for co-stimulation", "context": "waiting for context", } +DEFAULT_COSTIM_WEIGHTS = { + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, +} +DEFAULT_DOC_CONFIG = { + "anergy_ttl_seconds": 21600.0, + "related_lookback_seconds": 3600.0, + "related_pamps_saturation": 5.0, + "danger_saturation": 2.5, + "damp_danger_weight": 1.5, + "co_stimulation_threshold": 0.65, + "co_stimulation_weights": DEFAULT_COSTIM_WEIGHTS, + "novelty_window_seconds": 86400.0, + "context_recent_window_seconds": 1800.0, + "effector_threshold": 0.70, + "effector_min_related_count": 4, + "effector_cooldown_seconds": 1800.0, + "memory_threshold": 0.60, + "memory_trend_ratio_max": 0.60, + "memory_min_related_count": 3, + "state_wait_timeout_seconds": 3600.0, +} def parse_args() -> argparse.Namespace: @@ -451,6 +474,54 @@ def safe_div(num: float, den: float) -> float: return num / den +def normalize_costim_weights(weights: Any) -> dict[str, float]: + if not isinstance(weights, dict): + weights = {} + sanitized = {} + for key, default_value in DEFAULT_COSTIM_WEIGHTS.items(): + raw_value = weights.get(key, default_value) + try: + raw_value = float(raw_value) + except (TypeError, ValueError): + raw_value = default_value + sanitized[key] = max(0.0, raw_value) + + total = sum(sanitized.values()) + if total <= 0: + total = sum(DEFAULT_COSTIM_WEIGHTS.values()) + sanitized = DEFAULT_COSTIM_WEIGHTS.copy() + return {key: value / total for key, value in sanitized.items()} + + +def coerce_time_window_width(raw_value: Any) -> float: + if raw_value in (None, ""): + return float(DEFAULT_DOC_CONFIG["state_wait_timeout_seconds"]) + try: + return float(raw_value) + except (TypeError, ValueError): + text = str(raw_value) + if "only_one_tw" in text: + return 9999999999.0 + return float(DEFAULT_DOC_CONFIG["state_wait_timeout_seconds"]) + + +def report_config_with_defaults(report: dict) -> dict: + config = dict(report.get("config") or {}) + merged = {} + for key, default_value in DEFAULT_DOC_CONFIG.items(): + raw_value = config.get(key, default_value) + if key == "co_stimulation_weights": + merged[key] = normalize_costim_weights(raw_value) + continue + if key == "state_wait_timeout_seconds": + merged[key] = coerce_time_window_width(raw_value) + continue + if raw_value in (None, "", {}): + raw_value = default_value + merged[key] = raw_value + return merged + + def build_findings(report: dict) -> list[str]: totals = report["totals"] categories = report["observation_categories"] @@ -612,6 +683,523 @@ def bucket_items( } +def trace_row_cell_key(entry: dict) -> str: + if entry.get("cell_key"): + return str(entry.get("cell_key")) + candidate = entry.get("candidate") or {} + responsible_ip = str(entry.get("responsible_ip") or "") + regex_type = str(candidate.get("regex_type") or "") + antigen_value = str(candidate.get("value") or "") + if responsible_ip and regex_type and antigen_value: + return f"{responsible_ip}|{regex_type}|{antigen_value}" + return "" + + +def describe_current_evidence(current_evidence: dict | None) -> str: + current_evidence = current_evidence or {} + evidence_id = current_evidence.get("evidence_id") or "n/a" + evidence_type = current_evidence.get("evidence_type") or "unknown" + signal = current_evidence.get("signal") or "unknown" + confidence = format_float(current_evidence.get("confidence")) + threat_level = current_evidence.get("threat_level") or "unknown" + threat_level_value = format_float(current_evidence.get("threat_level_value")) + danger = format_float(current_evidence.get("danger_contribution")) + observation_id = current_evidence.get("observation_id") + observation_part = ( + f"obs={observation_id} | " if observation_id not in (None, "") else "" + ) + return ( + f"{observation_part}eid={evidence_id} | {evidence_type} | {signal} | " + f"conf={confidence} | threat={threat_level} ({threat_level_value}) | " + f"danger={danger}" + ) + + +def describe_observation_row(observation: dict | None) -> str: + observation = observation or {} + if not observation: + return "no linked observation row" + return ( + f"obs={observation.get('id')} | eid={observation.get('evidence_id')} | " + f"{observation.get('evidence_type')} | {observation.get('evidence_signal')} | " + f"conf={format_float(observation.get('confidence'))} | " + f"threat={observation.get('threat_level')} " + f"({format_float(observation.get('threat_level_value'))}) | " + f"antigens={summarize_antigens(observation.get('antigens') or [])} | " + f"matches={summarize_matched_regexes(observation.get('matched_regexes') or [])}" + ) + + +def describe_trace_contributor(prefix: str, contributor: dict) -> str: + relations = contributor.get("relations") or [] + relations_text = f" | relations={','.join(relations)}" if relations else "" + return ( + f"{prefix}: obs={contributor.get('observation_id')} | " + f"eid={contributor.get('evidence_id')} | {contributor.get('evidence_type')} | " + f"{contributor.get('signal')} | conf={format_float(contributor.get('confidence'))} | " + f"threat={contributor.get('threat_level')} " + f"({format_float(contributor.get('threat_level_value'))}) | " + f"danger={format_float(contributor.get('danger_contribution'))}" + f"{relations_text}" + ) + + +def summarize_lines(lines: list[str], fallback: str = "n/a", limit: int = 2) -> str: + cleaned = [str(line).strip() for line in lines if str(line).strip()] + if not cleaned: + return fallback + summary = " | ".join(cleaned[:limit]) + if len(cleaned) > limit: + summary += f" | +{len(cleaned) - limit} more" + return summary + + +def generic_threshold_result(scores: dict) -> tuple[str, list[str]]: + if not isinstance(scores, dict): + return ("n/a", ["No threshold snapshot was stored for this transition."]) + + if "value" in scores and "threshold" in scores: + value = scores.get("value") + threshold = scores.get("threshold") + passed = float(value) >= float(threshold) + comparator = ">=" if passed else "<" + status = "passed" if passed else "failed" + return ( + f"{status}: {format_float(value)} {comparator} {format_float(threshold)}", + [ + f"value={format_float(value)}", + f"threshold={format_float(threshold)}", + ], + ) + + result_lines = [] + summary_bits = [] + if "effector_score" in scores and "effector_threshold" in scores: + passed = float(scores["effector_score"]) >= float(scores["effector_threshold"]) + summary_bits.append( + "effector " + + ("passed" if passed else "failed") + + f": {format_float(scores['effector_score'])} " + + (">=" if passed else "<") + + f" {format_float(scores['effector_threshold'])}" + ) + result_lines.append(summary_bits[-1]) + if "memory_score" in scores and "memory_threshold" in scores: + passed = float(scores["memory_score"]) >= float(scores["memory_threshold"]) + summary_bits.append( + "memory " + + ("passed" if passed else "failed") + + f": {format_float(scores['memory_score'])} " + + (">=" if passed else "<") + + f" {format_float(scores['memory_threshold'])}" + ) + result_lines.append(summary_bits[-1]) + if not summary_bits: + return ("n/a", ["No threshold keys were stored in this score snapshot."]) + return (" | ".join(summary_bits), result_lines) + + +def build_trace_threshold_result(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + action = entry.get("action") or "" + if stage == "co_stimulation": + value = formula.get("value") + threshold = formula.get("threshold") + if value is None or threshold is None: + return ("n/a", ["Missing co-stimulation value or threshold."]) + passed = float(value) >= float(threshold) + comparator = ">=" if passed else "<" + return ( + f"{'passed' if passed else 'failed'}: " + f"{format_float(value)} {comparator} {format_float(threshold)}", + [ + f"action={action}", + f"value={format_float(value)}", + f"threshold={format_float(threshold)}", + ], + ) + + if stage == "context": + decision = formula.get("decision") or {} + effector = bool(decision.get("effector")) + memory = bool(decision.get("memory")) + effector_score = formula.get("effector_score") + effector_threshold = formula.get("effector_threshold") + memory_score = formula.get("memory_score") + memory_threshold = formula.get("memory_threshold") + summary = ( + f"effector={'yes' if effector else 'no'} " + f"({format_float(effector_score)} / {format_float(effector_threshold)}) | " + f"memory={'yes' if memory else 'no'} " + f"({format_float(memory_score)} / {format_float(memory_threshold)})" + ) + return ( + summary, + [ + f"action={action}", + f"effector decision={'passed' if effector else 'failed'}", + f"memory decision={'passed' if memory else 'failed'}", + f"effector_score={format_float(effector_score)} threshold={format_float(effector_threshold)}", + f"memory_score={format_float(memory_score)} threshold={format_float(memory_threshold)}", + ], + ) + return ("n/a", ["No threshold formatter for this trace stage."]) + + +def build_trace_considered_evidence(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + lines = [] + current_evidence = entry.get("current_evidence") or {} + if current_evidence: + lines.append("current: " + describe_current_evidence(current_evidence)) + + components = formula.get("components") or {} + if stage == "co_stimulation": + related = (components.get("related_pamps") or {}).get("contributors") or [] + danger = components.get("danger") or {} + pamp_contributors = danger.get("pamp_contributors") or [] + damp_contributors = danger.get("damp_contributors") or [] + for contributor in related: + lines.append(describe_trace_contributor("related_pamp", contributor)) + for contributor in pamp_contributors: + lines.append(describe_trace_contributor("danger_pamp", contributor)) + for contributor in damp_contributors: + lines.append(describe_trace_contributor("danger_damp", contributor)) + elif stage == "context": + recent_related = (components.get("recent_related") or {}).get( + "contributors" + ) or [] + recent_pressure = components.get("recent_pressure") or {} + previous_pressure = components.get("previous_pressure") or {} + for contributor in recent_related: + lines.append(describe_trace_contributor("recent_related", contributor)) + for contributor in recent_pressure.get("pamp_contributors") or []: + lines.append(describe_trace_contributor("recent_pressure_pamp", contributor)) + for contributor in recent_pressure.get("damp_contributors") or []: + lines.append(describe_trace_contributor("recent_pressure_damp", contributor)) + for contributor in previous_pressure.get("pamp_contributors") or []: + lines.append(describe_trace_contributor("previous_pressure_pamp", contributor)) + for contributor in previous_pressure.get("damp_contributors") or []: + lines.append(describe_trace_contributor("previous_pressure_damp", contributor)) + + if not lines: + lines.append("No contributor evidence snapshot was stored for this event.") + return (summarize_lines(lines, fallback="no stored evidence inputs"), lines) + + +def build_trace_computation_lines(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + if stage == "co_stimulation": + components = formula.get("components") or {} + confidence = components.get("confidence") or {} + related = components.get("related_pamps") or {} + danger = components.get("danger") or {} + lines = [ + f"value={format_float(formula.get('value'))}", + f"threshold={format_float(formula.get('threshold'))}", + ( + "confidence: value=" + f"{format_float(confidence.get('value'))} weighted=" + f"{format_float(confidence.get('weighted'))}" + ), + ( + "related_pamps: count=" + f"{related.get('count', 'n/a')} saturation=" + f"{format_float(related.get('saturation'))} score=" + f"{format_float(related.get('score'))} weighted=" + f"{format_float(related.get('weighted'))}" + ), + ( + "danger: score=" + f"{format_float(danger.get('score'))} weighted=" + f"{format_float(danger.get('weighted'))} pamp_score=" + f"{format_float(danger.get('pamp_score'))} damp_score=" + f"{format_float(danger.get('damp_score'))} damp_weight=" + f"{format_float(danger.get('damp_weight'))} saturation=" + f"{format_float(danger.get('danger_saturation'))}" + ), + ] + return (summarize_trace_formula(formula, stage), lines) + + if stage == "context": + components = formula.get("components") or {} + novelty = components.get("novelty") or {} + recent_related = components.get("recent_related") or {} + recent_pressure = components.get("recent_pressure") or {} + previous_pressure = components.get("previous_pressure") or {} + lines = [ + ( + "effector_score=" + f"{format_float(formula.get('effector_score'))} threshold=" + f"{format_float(formula.get('effector_threshold'))}" + ), + ( + "memory_score=" + f"{format_float(formula.get('memory_score'))} threshold=" + f"{format_float(formula.get('memory_threshold'))}" + ), + ( + "decision flags: effector=" + f"{'yes' if (formula.get('decision') or {}).get('effector') else 'no'} " + "memory=" + f"{'yes' if (formula.get('decision') or {}).get('memory') else 'no'}" + ), + ( + "novelty: score=" + f"{format_float(novelty.get('score'))} has_memory=" + f"{'yes' if novelty.get('has_memory_for_regex') else 'no'} " + "recent_activity=" + f"{'yes' if novelty.get('has_recent_regex_activity') else 'no'}" + ), + ( + "recent_related: count=" + f"{recent_related.get('count', 'n/a')} saturation=" + f"{format_float(recent_related.get('saturation'))} score=" + f"{format_float(recent_related.get('score'))}" + ), + ( + "recent_pressure: combined=" + f"{format_float(recent_pressure.get('combined_score'))} pamp=" + f"{format_float(recent_pressure.get('pamp_score'))} damp=" + f"{format_float(recent_pressure.get('damp_score'))} " + "raw_pamp=" + f"{format_float(recent_pressure.get('pamp_total_raw'))} raw_damp=" + f"{format_float(recent_pressure.get('damp_total_raw'))}" + ), + ( + "previous_pressure: combined=" + f"{format_float(previous_pressure.get('combined_score'))} pamp=" + f"{format_float(previous_pressure.get('pamp_score'))} damp=" + f"{format_float(previous_pressure.get('damp_score'))} " + "raw_pamp=" + f"{format_float(previous_pressure.get('pamp_total_raw'))} raw_damp=" + f"{format_float(previous_pressure.get('damp_total_raw'))}" + ), + f"trend_ratio={format_float(components.get('trend_ratio'))}", + f"decrease_score={format_float(components.get('decrease_score'))}", + f"familiarity_score={format_float(components.get('familiarity_score'))}", + f"stability_score={format_float(components.get('stability_score'))}", + ] + return (summarize_trace_formula(formula, stage), lines) + + return ("n/a", ["No computation formatter for this trace stage."]) + + +def build_transition_computation_lines(transition: dict) -> tuple[str, list[str]]: + scores = transition.get("scores") or {} + if not scores: + return ("no score snapshot", ["This transition stored no score payload."]) + lines = [f"{key}={format_float(value)}" for key, value in sorted(scores.items())] + return (summarize_lines(lines, fallback="score snapshot"), lines) + + +def build_transition_event( + transition: dict, observations_by_id: dict[int, dict] +) -> dict: + observation = observations_by_id.get(int(transition.get("observation_id") or 0), {}) + threshold_summary, threshold_lines = generic_threshold_result( + transition.get("scores") or {} + ) + computation_summary, computation_lines = build_transition_computation_lines( + transition + ) + evidence_lines = [describe_observation_row(observation)] + return { + "ts": transition.get("created_at"), + "wall": ts_to_iso(transition.get("created_at")), + "source": "State transition", + "step": transition.get("reason") or "transition", + "stage": "transition", + "state_path": ( + f"{state_label(transition.get('from_state'))} → " + f"{state_label(transition.get('to_state'))}" + ), + "evidence_id": transition.get("evidence_id") or "", + "threshold_summary": threshold_summary, + "threshold_lines": threshold_lines, + "considered_summary": summarize_lines(evidence_lines), + "considered_lines": evidence_lines, + "computation_summary": computation_summary, + "computation_lines": computation_lines, + "priority": 2, + } + + +def build_trace_event(entry: dict) -> dict: + threshold_summary, threshold_lines = build_trace_threshold_result(entry) + considered_summary, considered_lines = build_trace_considered_evidence(entry) + computation_summary, computation_lines = build_trace_computation_lines(entry) + current_evidence = entry.get("current_evidence") or {} + evidence_id = current_evidence.get("evidence_id") or "" + evidence_type = current_evidence.get("evidence_type") or "" + signal = current_evidence.get("signal") or "" + if evidence_type or signal: + evidence_label = f"{evidence_id} | {evidence_type} | {signal}".strip(" |") + else: + evidence_label = evidence_id or "n/a" + return { + "ts": entry.get("_ts"), + "wall": entry.get("ts") or ts_to_iso(entry.get("_ts")), + "source": "Decision trace", + "step": f"{entry.get('stage') or 'trace'}: {entry.get('action') or 'event'}", + "stage": entry.get("stage") or "trace", + "state_path": ( + f"{entry.get('from_state') or 'n/a'} → {entry.get('to_state') or 'n/a'}" + ), + "evidence_id": evidence_label, + "threshold_summary": threshold_summary, + "threshold_lines": threshold_lines, + "considered_summary": considered_summary, + "considered_lines": considered_lines, + "computation_summary": computation_summary, + "computation_lines": computation_lines, + "priority": 1, + } + + +def build_life_path( + transitions_for_cell: list[dict], current_state_label: str | None +) -> str: + ordered = sorted( + transitions_for_cell, + key=lambda item: (float(item.get("created_at") or 0.0), int(item.get("id") or 0)), + ) + states = [] + for transition in ordered: + from_label = state_label(transition.get("from_state")) + to_label = state_label(transition.get("to_state")) + if not states: + states.append(from_label) + if states[-1] != from_label: + states.append(from_label) + if states[-1] != to_label: + states.append(to_label) + if not states and current_state_label: + states = [current_state_label] + elif current_state_label and states[-1] != current_state_label: + states.append(current_state_label) + return " → ".join(states) if states else "no recorded state changes" + + +def build_cell_histories( + observations: list[dict], + cells: list[dict], + transitions: list[dict], + trace_rows: list[dict], +) -> list[dict]: + observations_by_id = { + int(observation["id"]): observation + for observation in observations + if observation.get("id") is not None + } + cells_by_key = { + str(cell.get("cell_key")): cell + for cell in cells + if cell.get("cell_key") + } + transitions_by_cell: dict[str, list[dict]] = defaultdict(list) + for transition in transitions: + cell_key = str(transition.get("cell_key") or "") + if cell_key: + transitions_by_cell[cell_key].append(transition) + + traces_by_cell: dict[str, list[dict]] = defaultdict(list) + for entry in trace_rows: + cell_key = trace_row_cell_key(entry) + if cell_key: + traces_by_cell[cell_key].append(entry) + + cell_keys = set(cells_by_key) | set(transitions_by_cell) | set(traces_by_cell) + histories = [] + for cell_key in sorted(cell_keys): + cell = cells_by_key.get(cell_key, {}) + cell_transitions = transitions_by_cell.get(cell_key, []) + cell_traces = traces_by_cell.get(cell_key, []) + + events = [build_trace_event(entry) for entry in cell_traces] + events.extend( + build_transition_event(transition, observations_by_id) + for transition in cell_transitions + ) + events.sort( + key=lambda item: ( + item.get("ts") is None, + float(item.get("ts") or 0.0), + int(item.get("priority") or 9), + item.get("step") or "", + ) + ) + + current_state_label = state_label(cell.get("state")) if cell else None + waiting_label = cell_waiting_label(cell) if cell else "" + first_ts_candidates = [ + float(item.get("ts")) + for item in events + if item.get("ts") is not None + ] + if cell.get("created_at") is not None: + first_ts_candidates.append(float(cell.get("created_at"))) + last_ts_candidates = [ + float(item.get("ts")) + for item in events + if item.get("ts") is not None + ] + if cell.get("updated_at") is not None: + last_ts_candidates.append(float(cell.get("updated_at"))) + first_seen = min(first_ts_candidates) if first_ts_candidates else None + last_seen = max(last_ts_candidates) if last_ts_candidates else None + current_state_display = current_state_label or "unknown" + if waiting_label: + current_state_display += f" ({waiting_label})" + + histories.append( + { + "cell_key": cell_key, + "responsible_ip": cell.get("responsible_ip") + or ( + cell_transitions[0].get("profile_ip") + if cell_transitions + else (cell_traces[0].get("responsible_ip") if cell_traces else "") + ), + "regex_type": cell.get("regex_type") + or ( + cell_transitions[0].get("regex_type") + if cell_transitions + else ((cell_traces[0].get("candidate") or {}).get("regex_type", "")) + ), + "antigen_value": cell.get("antigen_value") + or ( + cell_transitions[0].get("antigen_value") + if cell_transitions + else ((cell_traces[0].get("candidate") or {}).get("value", "")) + ), + "matched_value": cell.get("matched_value") + or ( + cell_transitions[-1].get("matched_value") + if cell_transitions + else ((cell_traces[-1].get("match") or {}).get("value", "")) + ), + "current_state": current_state_display, + "current_state_class": state_class(cell.get("state")) + if cell + else "state-unknown", + "waiting_label": waiting_label, + "life_path": build_life_path(cell_transitions, current_state_label), + "first_seen": ts_to_iso(first_seen), + "last_seen": ts_to_iso(last_seen), + "event_count": len(events), + "transition_count": len(cell_transitions), + "trace_count": len(cell_traces), + "events": events, + } + ) + + return histories + + def build_report_payload( run_output_dir: Path, max_observations: int = 200, @@ -631,7 +1219,9 @@ def build_report_payload( memories = db_records["memories"] log_data = load_log_entries(log_path, max_log_lines) trace_rows = load_trace_entries(trace_path) - config = load_yaml_config(metadata_path).get("t_cell", {}) + metadata = load_yaml_config(metadata_path) + config = metadata.get("t_cell", {}) + parameters = metadata.get("parameters", {}) transitions_by_observation: dict[int, list[dict]] = defaultdict(list) for transition in transitions: @@ -825,6 +1415,12 @@ def build_report_payload( } for entry in log_data["entries"][-max(1, max_log_lines) :] ] + cell_histories = build_cell_histories( + observations=observations, + cells=cells, + transitions=transitions, + trace_rows=trace_rows, + ) report = { "generated_at": now_iso(), @@ -843,11 +1439,29 @@ def build_report_payload( "log_verbosity": config.get("log_verbosity"), "decision_trace_mode": config.get("decision_trace_mode"), "related_lookback_seconds": config.get("related_lookback_seconds"), + "related_pamps_saturation": config.get("related_pamps_saturation"), + "danger_saturation": config.get("danger_saturation"), + "damp_danger_weight": config.get("damp_danger_weight"), "co_stimulation_threshold": config.get("co_stimulation_threshold"), + "co_stimulation_weights": normalize_costim_weights( + config.get("co_stimulation_weights") + ), + "novelty_window_seconds": config.get("novelty_window_seconds"), + "context_recent_window_seconds": config.get( + "context_recent_window_seconds" + ), "effector_threshold": config.get("effector_threshold"), + "effector_min_related_count": config.get( + "effector_min_related_count" + ), "memory_threshold": config.get("memory_threshold"), + "memory_trend_ratio_max": config.get("memory_trend_ratio_max"), + "memory_min_related_count": config.get("memory_min_related_count"), "anergy_ttl_seconds": config.get("anergy_ttl_seconds"), "effector_cooldown_seconds": config.get("effector_cooldown_seconds"), + "state_wait_timeout_seconds": coerce_time_window_width( + parameters.get("time_window_width") + ), }, "totals": { "observations": len(observations), @@ -893,6 +1507,7 @@ def build_report_payload( "rows": recent_trace_rows[: max(1, max_trace_rows)], "total_rows": len(trace_rows), }, + "cell_histories": cell_histories, "log": { "rows": recent_log_rows, "tail_text": "\n".join(log_data["tail"]), @@ -1430,6 +2045,723 @@ def render_pretty_json(value: Any) -> str: return escape(json.dumps(value, indent=2, sort_keys=True)) +def render_formula_box(lines: list[str]) -> str: + return ( + "
    "
    +        + escape("\n".join(lines))
    +        + "
    " + ) + + +def render_term_cards(terms: list[dict]) -> str: + return "".join( + f""" +
    +

    {escape(term['label'])}

    +

    {escape(term['formula'])}

    +

    {escape(term['description'])}

    +
    + """ + for term in terms + ) + + +def render_formula_tree_node(node: dict) -> str: + children = node.get("children") or [] + child_class = "formula-children" + if len(children) > 1: + child_class += " has-multiple" + tooltip = node.get("tooltip") or "" + formula = node.get("formula") or "" + summary = node.get("summary") or "" + children_html = "" + if children: + children_html = ( + f"
    " + + "".join( + "
    " + + render_formula_tree_node(child) + + "
    " + for child in children + ) + + "
    " + ) + return f""" +
    +
    + {escape(node.get('label', 'value'))} + {f"{escape(formula)}" if formula else ""} + {f"{escape(summary)}" if summary else ""} + {f"{escape(tooltip)}" if tooltip else ""} +
    + {children_html} +
    + """ + + +def render_formula_tree(node: dict) -> str: + return f"
    {render_formula_tree_node(node)}
    " + + +def render_decision_doc_card(card: dict) -> str: + equation_html = render_formula_box(card["equation_lines"]) + gate_html = render_formula_box(card["gate_lines"]) + term_cards_html = render_term_cards(card["terms"]) + tree_html = render_formula_tree(card["tree"]) + notes_html = "".join( + f"

    {escape(note)}

    " + for note in card.get("notes", []) + ) + return f""" +
    +
    +

    {escape(card['title'])}

    +

    {escape(card['summary'])}

    +
    +
    +
    +

    Exact Equation

    + {equation_html} +
    +
    +

    Decision Gate

    + {gate_html} +
    +
    + {notes_html} +
    + {term_cards_html} +
    +
    +
    +

    Input Tree

    +

    Hover or focus a node to see where that term comes from.

    +
    + {tree_html} +
    +
    + """ + + +def render_rule_cards(cards: list[dict]) -> str: + return "".join( + f""" +
    +

    {escape(card['title'])}

    +

    {escape(card['rule'])}

    +

    {escape(card['description'])}

    +
    + """ + for card in cards + ) + + +def render_decision_reference(report: dict) -> str: + config = report_config_with_defaults(report) + weights = config["co_stimulation_weights"] + related_lookback = format_float(config["related_lookback_seconds"]) + related_saturation = format_float(config["related_pamps_saturation"]) + danger_saturation = format_float(config["danger_saturation"]) + damp_weight = format_float(config["damp_danger_weight"]) + novelty_window = format_float(config["novelty_window_seconds"]) + context_window = format_float(config["context_recent_window_seconds"]) + wait_limit = format_float(config["state_wait_timeout_seconds"]) + co_threshold = format_float(config["co_stimulation_threshold"]) + effector_threshold = format_float(config["effector_threshold"]) + effector_min_related = str(int(config["effector_min_related_count"])) + effector_cooldown = format_float(config["effector_cooldown_seconds"]) + memory_threshold = format_float(config["memory_threshold"]) + memory_ratio_max = format_float(config["memory_trend_ratio_max"]) + memory_min_related = str(int(config["memory_min_related_count"])) + anergy_ttl = format_float(config["anergy_ttl_seconds"]) + + decision_cards = [ + { + "title": "Co-Stimulation: 1 -> 3 activation", + "summary": "This score is evaluated after antigen recognition to decide whether the cell activates.", + "equation_lines": [ + ( + "co_stimulation = " + f"{format_float(weights['confidence'])} * confidence" + ), + ( + f" + {format_float(weights['related_pamps'])} " + "* related_pamp_score" + ), + ( + f" + {format_float(weights['danger'])} " + "* profile_danger_score" + ), + ], + "gate_lines": [ + f"activate when co_stimulation >= {co_threshold}", + "otherwise stay in 1 - antigen-recognized", + f"timeout to 2 - anergic after {wait_limit}s if still below threshold", + ], + "notes": [ + f"Related PAMPs are counted over the last {related_lookback}s for the same responsible IP.", + "A related PAMP shares either the same antigen value or the same matched regex hash. The current observation is excluded from that count.", + "DAMP observations never create cells, but they do raise the mixed danger term used here.", + ], + "terms": [ + { + "label": "confidence", + "formula": "current evidence.confidence", + "description": "The confidence carried by the observation that is being evaluated right now.", + }, + { + "label": "related_pamp_score", + "formula": f"clamp01(related_pamp_count / {related_saturation})", + "description": "How much recent, related PAMP evidence reinforces the same antigen or regex identity.", + }, + { + "label": "profile_danger_score", + "formula": ( + "clamp01((pamp_raw + " + f"{damp_weight} * damp_raw) / {danger_saturation})" + ), + "description": "The mixed danger pressure for the same responsible IP, with DAMP raw danger amplified before normalization.", + }, + { + "label": "pamp_raw / damp_raw", + "formula": "sum(threat_level_value * confidence)", + "description": "Raw danger is the sum of threat level value multiplied by confidence across recent PAMP or DAMP observations.", + }, + ], + "tree": { + "label": "co_stimulation", + "formula": ( + f"{format_float(weights['confidence'])} * confidence + " + f"{format_float(weights['related_pamps'])} * related_pamp_score + " + f"{format_float(weights['danger'])} * profile_danger_score" + ), + "summary": f"Activation score. Threshold = {co_threshold}", + "tooltip": "Final co-stimulation score used for the 1 -> 3 decision.", + "children": [ + { + "label": "confidence", + "formula": "current evidence.confidence", + "summary": "Current PAMP confidence", + "tooltip": "Read directly from the observation currently being processed.", + }, + { + "label": "related_pamp_score", + "formula": f"clamp01(related_pamp_count / {related_saturation})", + "summary": "Recent related PAMP reinforcement", + "tooltip": "Normalized count of related PAMP observations in the related lookback window.", + "children": [ + { + "label": "related_pamp_count", + "formula": "count of related recent PAMPs", + "summary": "Same antigen value or same matched regex hash", + "tooltip": ( + f"Counted over the last {related_lookback}s for the same responsible IP. " + "The current observation is excluded." + ), + }, + { + "label": "related_pamps_saturation", + "formula": related_saturation, + "summary": "Count where the score saturates at 1", + "tooltip": "If the count reaches this value, related_pamp_score stops increasing.", + }, + ], + }, + { + "label": "profile_danger_score", + "formula": ( + "clamp01((pamp_raw + " + f"{damp_weight} * damp_raw) / {danger_saturation})" + ), + "summary": "Normalized mixed danger for the responsible IP", + "tooltip": "Recent PAMP and DAMP danger are combined, then clamped into the 0..1 range.", + "children": [ + { + "label": "pamp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent PAMP raw danger", + "tooltip": ( + f"Summed over PAMP observations for the same responsible IP within the last {related_lookback}s." + ), + }, + { + "label": "damp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent DAMP raw danger", + "tooltip": ( + f"Summed over DAMP observations for the same responsible IP within the last {related_lookback}s." + ), + }, + { + "label": "damp_danger_weight", + "formula": damp_weight, + "summary": "Amplifies DAMP raw danger before normalization", + "tooltip": "DAMP pressure is scaled before it is added into the mixed danger term.", + }, + { + "label": "danger_saturation", + "formula": danger_saturation, + "summary": "Raw danger amount that maps to score 1", + "tooltip": "The combined raw danger is divided by this value before clamp01 is applied.", + }, + ], + }, + ], + }, + }, + { + "title": "Context Effector: 3 -> 4 containment", + "summary": "This score evaluates whether an activated cell should escalate into an effector response.", + "equation_lines": [ + "effector_score = 0.45 * recent_pressure", + " + 0.25 * recent_related_score", + " + 0.30 * novelty_score", + ], + "gate_lines": [ + "effector = (novelty_score > 0)", + f" and (recent_related_count >= {effector_min_related})", + f" and (effector_score >= {effector_threshold})", + ], + "notes": [ + f"recent_pressure is computed over the last {context_window}s and uses the same mixed PAMP + weighted DAMP danger model as co-stimulation.", + f"novelty_score is binary: it becomes 1 only if the matched regex has no stored memory row and no recent transition activity in the last {novelty_window}s.", + f"If the cell reaches state 4, repeated containment is still gated by an effector cooldown of {effector_cooldown}s.", + ], + "terms": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "description": "The normalized mixed danger in the recent context window for the same responsible IP.", + }, + { + "label": "recent_related_score", + "formula": f"clamp01(recent_related_count / {related_saturation})", + "description": "How much recent PAMP evidence in the context window still points to the same antigen or regex identity.", + }, + { + "label": "novelty_score", + "formula": "1 if no memory and no recent regex activity else 0", + "description": "A binary novelty gate. If the regex is already familiar, the effector path is blocked immediately.", + }, + ], + "tree": { + "label": "effector_score", + "formula": "0.45 * recent_pressure + 0.25 * recent_related_score + 0.30 * novelty_score", + "summary": f"Containment score. Threshold = {effector_threshold}", + "tooltip": "Final context score used to decide whether state 3 escalates to state 4.", + "children": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger during the most recent {context_window}s window", + "tooltip": "Computed from the recent context window immediately before the current decision.", + "children": [ + { + "label": "recent_pamp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent PAMP raw danger", + "tooltip": "Summed over recent PAMP observations in the context window.", + }, + { + "label": "recent_damp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent DAMP raw danger", + "tooltip": "Summed over recent DAMP observations in the context window.", + }, + ], + }, + { + "label": "recent_related_score", + "formula": f"clamp01(recent_related_count / {related_saturation})", + "summary": "Recent supporting PAMP count normalized to 0..1", + "tooltip": "Counts related PAMP observations in the recent context window.", + "children": [ + { + "label": "recent_related_count", + "formula": "count of related recent PAMPs", + "summary": "Same antigen value or same matched regex hash", + "tooltip": ( + f"Counted only inside the recent context window of {context_window}s." + ), + }, + { + "label": "related_pamps_saturation", + "formula": related_saturation, + "summary": "Cap for the normalized related score", + "tooltip": "The count is divided by this saturation value before clamp01 is applied.", + }, + ], + }, + { + "label": "novelty_score", + "formula": "1 if no memory and no recent activity else 0", + "summary": "Binary novelty gate", + "tooltip": "Effector requires the regex to still look new for this responsible IP.", + "children": [ + { + "label": "has_memory_for_regex", + "formula": "memory row exists for regex_hash", + "summary": "If true, novelty_score becomes 0", + "tooltip": "A stored memory for the regex marks it as familiar immediately.", + }, + { + "label": "has_recent_regex_activity", + "formula": f"transition activity within {novelty_window}s", + "summary": "If true, novelty_score becomes 0", + "tooltip": "Any recent transition for the same responsible IP and regex hash removes novelty.", + }, + ], + }, + ], + }, + }, + { + "title": "Context Memory: 3 -> 5 storage", + "summary": "This score evaluates whether an activated cell should store memory instead of escalating to containment.", + "equation_lines": [ + "memory_score = 0.60 * decrease_score", + " + 0.25 * familiarity_score", + " + 0.15 * stability_score", + ], + "gate_lines": [ + "memory = (familiarity_score > 0)", + f" and (recent_related_count >= {memory_min_related})", + f" and (trend_ratio <= {memory_ratio_max})", + f" and (memory_score >= {memory_threshold})", + ], + "notes": [ + "Memory is the cooling-down path: the same pattern is no longer novel, pressure is lower than before, and enough related evidence still supports the match.", + "trend_ratio compares the recent mixed pressure window against the previous adjacent window. Lower is better for memory.", + f"If neither effector nor memory passes, the cell stays in 3 - activated until the context wait timeout of {wait_limit}s expires.", + ], + "terms": [ + { + "label": "decrease_score", + "formula": "clamp01(1 - trend_ratio)", + "description": "Rewards situations where recent pressure is clearly lower than previous pressure.", + }, + { + "label": "trend_ratio", + "formula": "recent_pressure / max(previous_pressure, 0.01)", + "description": "Measures whether the mixed danger is falling, flat, or rising between adjacent context windows.", + }, + { + "label": "familiarity_score", + "formula": "1 - novelty_score", + "description": "Memory requires the regex to already be familiar rather than novel.", + }, + { + "label": "stability_score", + "formula": f"clamp01(recent_related_count / {memory_min_related})", + "description": "Ensures there is still enough related recent evidence to justify storing memory.", + }, + ], + "tree": { + "label": "memory_score", + "formula": "0.60 * decrease_score + 0.25 * familiarity_score + 0.15 * stability_score", + "summary": f"Memory score. Threshold = {memory_threshold}", + "tooltip": "Final context score used to decide whether state 3 transitions into state 5.", + "children": [ + { + "label": "decrease_score", + "formula": "clamp01(1 - trend_ratio)", + "summary": "Higher when recent pressure is falling", + "tooltip": "A falling trend pushes the memory score up.", + "children": [ + { + "label": "trend_ratio", + "formula": "recent_pressure / max(previous_pressure, 0.01)", + "summary": f"Must stay <= {memory_ratio_max} for memory", + "tooltip": "Compares the most recent context window against the immediately preceding one.", + "children": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger over the last {context_window}s", + "tooltip": "Same recent pressure value also used by effector_score.", + }, + { + "label": "previous_pressure", + "formula": ( + "clamp01((previous_pamp_raw + " + f"{damp_weight} * previous_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger over the previous {context_window}s window", + "tooltip": "Computed over the context window immediately before the recent one.", + }, + ], + } + ], + }, + { + "label": "familiarity_score", + "formula": "1 - novelty_score", + "summary": "Higher when the regex is already familiar", + "tooltip": "Memory is only allowed once novelty has disappeared.", + "children": [ + { + "label": "novelty_score", + "formula": "1 if no memory and no recent activity else 0", + "summary": "Same novelty gate used by the effector path", + "tooltip": "If novelty_score stays 1, familiarity_score stays 0 and memory fails.", + } + ], + }, + { + "label": "stability_score", + "formula": f"clamp01(recent_related_count / {memory_min_related})", + "summary": "Recent evidence stability", + "tooltip": "Memory still requires enough related recent PAMPs to support the pattern.", + "children": [ + { + "label": "recent_related_count", + "formula": "count of related recent PAMPs", + "summary": f"Must stay >= {memory_min_related}", + "tooltip": "Related means same antigen value or same matched regex hash in the recent context window.", + } + ], + }, + ], + }, + }, + ] + + rule_cards = [ + { + "title": "Recognition", + "rule": "0 -> 1 when a PAMP has an extracted antigen and an accepted regex match", + "description": "If a PAMP has antigens but no accepted regex match, the cell instead goes 0 -> 2 and becomes anergic.", + }, + { + "title": "Anergy Expiry", + "rule": f"2 -> 0 when anergy_ttl_seconds ({anergy_ttl}s) has elapsed", + "description": "Once the anergy TTL expires, the cell returns to mature and can be evaluated again.", + }, + { + "title": "Co-Stimulation Timeout", + "rule": f"1 -> 2 when the co-stimulation wait reaches {wait_limit}s", + "description": "The cell can keep waiting in 1 - antigen-recognized while later PAMP or DAMP evidence reevaluates the score, but only for one configured Slips time window.", + }, + { + "title": "Context Timeout", + "rule": f"3 -> 0 when the context wait reaches {wait_limit}s", + "description": "If neither effector nor memory passes before the waiting window ends, the cell falls back to 0 - mature.", + }, + { + "title": "Effector Cooldown", + "rule": f"4 -> 4 suppress repeated containment until {effector_cooldown}s passes", + "description": "The state can stay effector while repeated blocking publications are suppressed by cooldown.", + }, + { + "title": "Memory Retention", + "rule": "5 -> 5 keep the memory state on later matching evidence", + "description": "Once memory is stored for that cell, later hits retain state 5 without writing repeated memory_stored events.", + }, + ] + + decision_cards_html = "".join( + render_decision_doc_card(card) for card in decision_cards + ) + rule_cards_html = render_rule_cards(rule_cards) + return f""" +
    +
    +

    Decision Reference

    +

    Bottom-of-report explanation of how the T Cell equations and branch conditions are computed.

    +
    +

    + This section documents the exact values, thresholds, and helper rules used by the report and by the T Cell module decision logic. + Normalization uses clamp01(x) = max(0, min(1, x)). Hover or focus a node in each tree to inspect where that term comes from. +

    +
    + {decision_cards_html} +
    +
    +
    +

    Rule-Based Decisions

    +

    These branches are not weighted equations, but they still change state or suppress actions.

    +
    +
    + {rule_cards_html} +
    +
    +
    + """ + + +def render_history_details(summary: str, lines: list[str]) -> str: + details_body = escape("\n".join(lines or ["n/a"])) + return ( + f"
    {escape(summary)}" + f"
    {details_body}
    " + ) + + +def render_history_event_table(events: list[dict]) -> str: + if not events: + return '

    No history events were recorded for this T cell.

    ' + + head = "".join( + f"{escape(column)}" + for column in [ + "When", + "Source", + "Step", + "State path", + "Evidence", + "Threshold result", + "Evidence considered", + "Computation", + ] + ) + rows = [] + for event in events: + row_cells = [ + escape(event.get("wall") or "n/a"), + escape(event.get("source") or "unknown"), + escape(event.get("step") or "event"), + escape(event.get("state_path") or "n/a"), + escape(event.get("evidence_id") or "n/a"), + render_history_details( + event.get("threshold_summary") or "n/a", + event.get("threshold_lines") or [], + ), + render_history_details( + event.get("considered_summary") or "n/a", + event.get("considered_lines") or [], + ), + render_history_details( + event.get("computation_summary") or "n/a", + event.get("computation_lines") or [], + ), + ] + rows.append( + "" + + "".join(f"{cell}" for cell in row_cells) + + "" + ) + return ( + "
    " + "" + f"{head}" + f"{''.join(rows)}
    " + ) + + +def render_cell_histories(report: dict) -> str: + histories = report.get("cell_histories") or [] + if not histories: + return """ +
    +

    T Cell Histories

    +

    No T-cell histories were available for this run.

    +
    + """ + + index_rows = [ + { + "Responsible": escape(item.get("responsible_ip") or ""), + "Cell": escape(shorten(item.get("cell_key") or "", 64)), + "Current state": render_badge( + item.get("current_state") or "unknown", + item.get("current_state_class") or "state-unknown", + ), + "Life path": escape(shorten(item.get("life_path") or "", 88)), + "Events": escape(str(item.get("event_count") or 0)), + } + for item in histories + ] + index_table = render_simple_table( + ["Responsible", "Cell", "Current state", "Life path", "Events"], + index_rows, + "No T-cell history index available.", + ) + + trace_mode = (report.get("config") or {}).get("decision_trace_mode") + if trace_mode in (None, "", {}): + trace_note = ( + "Decision trace configuration was not found in metadata, so histories rely on whatever trace rows and transitions were stored." + ) + elif str(trace_mode).lower() in {"0", "off"}: + trace_note = ( + "Decision trace was off for this run, so histories can only show state transitions and any score snapshots saved with those transitions." + ) + elif str(trace_mode).lower() in {"1", "transitions"}: + trace_note = ( + "Decision trace was limited to transition events, so waiting reevaluations may be missing from the lifecycle view." + ) + else: + trace_note = ( + "Decision trace was fully enabled, so histories include both state changes and intermediate decision evaluations when available." + ) + + history_cards = [] + for index, item in enumerate(histories): + title = ( + f"{item.get('responsible_ip') or 'unknown'} | " + f"{item.get('regex_type') or 'unknown'}:{item.get('antigen_value') or ''}" + ) + meta_bits = [ + f"current={item.get('current_state') or 'unknown'}", + f"life path={item.get('life_path') or 'n/a'}", + f"first seen={item.get('first_seen') or 'n/a'}", + f"last seen={item.get('last_seen') or 'n/a'}", + f"events={item.get('event_count') or 0}", + f"transitions={item.get('transition_count') or 0}", + f"trace rows={item.get('trace_count') or 0}", + ] + table_html = render_history_event_table(item.get("events") or []) + history_cards.append( + f""" +
    + +
    +
    +

    T Cell

    +

    {escape(title)}

    +

    {escape(' | '.join(meta_bits))}

    +
    +
    + {render_badge(item.get("current_state") or "unknown", item.get("current_state_class") or "state-unknown")} +
    +
    +
    +
    +

    Cell key: {escape(item.get('cell_key') or '')}

    +

    Matched value: {escape(item.get('matched_value') or 'n/a')}

    + {table_html} +
    +
    + """ + ) + + return f""" +
    +
    +

    T Cell Histories

    +

    Chronological lifecycle view for each cell, combining stored state transitions with decision-trace computations.

    +
    +

    {escape(trace_note)}

    +
    +

    History Index

    + {index_table} +
    +
    + {''.join(history_cards)} +
    +
    + """ + + def render_html(report: dict) -> str: findings_html = "".join( f"
  • {escape(item)}
  • " for item in report.get("findings", []) @@ -1633,6 +2965,8 @@ def render_html(report: dict) -> str: TRACE_STAGE_COLORS, ) state_machine_graph = render_state_machine_graph(report) + decision_reference = render_decision_reference(report) + histories_section = render_cell_histories(report) return f""" @@ -1699,13 +3033,52 @@ def render_html(report: dict) -> str: font-size: 0.80rem; word-break: break-all; }} - .summary-grid, .panel-grid, .stats-grid {{ - display: grid; - gap: 14px; - }} - .stats-grid {{ - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - }} + .summary-grid, .panel-grid, .stats-grid {{ + display: grid; + gap: 14px; + }} + .tab-strip {{ + display: inline-flex; + gap: 8px; + margin: 16px 0 4px; + padding: 6px; + border-radius: 999px; + background: rgba(255, 253, 248, 0.82); + border: 1px solid rgba(123, 83, 44, 0.12); + box-shadow: 0 12px 26px rgba(66, 43, 17, 0.07); + position: sticky; + top: 10px; + z-index: 8; + backdrop-filter: blur(8px); + }} + .tab-button {{ + border: 0; + border-radius: 999px; + padding: 10px 16px; + background: transparent; + color: var(--muted); + font: inherit; + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; + }} + .tab-button:hover {{ + color: #7c2d12; + }} + .tab-button.is-active {{ + background: linear-gradient(180deg, rgba(180, 83, 9, 0.12), rgba(180, 83, 9, 0.18)); + color: #7c2d12; + box-shadow: inset 0 0 0 1px rgba(180, 83, 9, 0.12); + }} + .report-tab-panel {{ + display: none; + }} + .report-tab-panel.is-active {{ + display: block; + }} + .stats-grid {{ + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + }} .panel-grid {{ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-top: 14px; @@ -1941,23 +3314,300 @@ def render_html(report: dict) -> str: .footer-panel .report-table {{ min-width: 480px; }} - .footer-panel .report-table th, - .footer-panel .report-table td {{ - font-size: 0.70rem; - padding: 5px 7px; - }} - @media (max-width: 900px) {{ - body {{ font-size: 13px; }} - main {{ padding: 16px 12px 40px; }} - .panel-grid {{ grid-template-columns: 1fr; }} - .report-table {{ min-width: 680px; }} - }} - + .footer-panel .report-table th, + .footer-panel .report-table td {{ + font-size: 0.70rem; + padding: 5px 7px; + }} + .decision-reference, + .decision-doc, + .tree-block {{ + overflow: visible; + }} + .decision-reference code, + .formula-box code, + .term-formula, + .formula-node-formula {{ + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + }} + .decision-lead {{ + margin: 0 0 14px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.55; + }} + .decision-doc-grid {{ + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + }} + .decision-doc {{ + border: 1px solid rgba(123, 83, 44, 0.12); + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 253, 248, 0.96), rgba(245, 237, 224, 0.96)); + padding: 14px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); + }} + .decision-doc .panel-head {{ + align-items: flex-start; + margin-bottom: 12px; + }} + .decision-doc .panel-head h3, + .tree-block .panel-head h4 {{ + margin: 0; + }} + .equation-grid {{ + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + }} + .formula-box {{ + margin: 0; + padding: 12px; + border-radius: 14px; + border: 1px solid rgba(180, 83, 9, 0.16); + background: linear-gradient(180deg, rgba(255, 250, 240, 0.98), rgba(252, 242, 227, 0.98)); + color: #6b3f07; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); + overflow: auto; + white-space: pre-wrap; + }} + .decision-note {{ + margin: 10px 0 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.5; + }} + .term-grid, + .rule-grid {{ + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-top: 12px; + }} + .term-card, + .rule-card {{ + background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + }} + .term-formula {{ + margin: 0 0 8px; + font-size: 0.74rem; + line-height: 1.5; + color: #92400e; + overflow-wrap: anywhere; + }} + .term-body {{ + margin: 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.52; + }} + .tree-block {{ + margin-top: 14px; + padding: 12px; + border-radius: 16px; + border: 1px solid rgba(123, 83, 44, 0.12); + background: rgba(255, 253, 248, 0.74); + }} + .formula-tree {{ + overflow: auto; + padding: 96px 6px 6px; + }} + .formula-node-wrap {{ + display: flex; + flex-direction: column; + align-items: center; + min-width: max-content; + position: relative; + }} + .formula-node {{ + position: relative; + display: grid; + gap: 4px; + min-width: 180px; + max-width: 260px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(123, 83, 44, 0.16); + background: linear-gradient(180deg, rgba(255, 253, 248, 0.99), rgba(248, 239, 226, 0.98)); + box-shadow: 0 12px 24px rgba(66, 43, 17, 0.08); + outline: none; + }} + .formula-node:hover, + .formula-node:focus {{ + border-color: rgba(180, 83, 9, 0.72); + box-shadow: 0 16px 28px rgba(180, 83, 9, 0.12); + }} + .formula-node-label {{ + font-weight: 700; + font-size: 0.82rem; + color: var(--ink); + }} + .formula-node-formula {{ + font-size: 0.71rem; + line-height: 1.45; + color: #92400e; + }} + .formula-node-summary {{ + font-size: 0.74rem; + line-height: 1.4; + color: var(--muted); + }} + .formula-tooltip {{ + position: absolute; + left: 50%; + bottom: calc(100% + 12px); + transform: translateX(-50%) translateY(6px); + min-width: 230px; + max-width: 320px; + padding: 10px 12px; + border-radius: 12px; + background: #1f2937; + color: #f8fafc; + font-size: 0.72rem; + line-height: 1.45; + box-shadow: 0 18px 28px rgba(15, 23, 42, 0.28); + opacity: 0; + pointer-events: none; + transition: opacity 140ms ease, transform 140ms ease; + z-index: 25; + }} + .formula-tooltip::after {{ + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 6px; + border-style: solid; + border-color: #1f2937 transparent transparent transparent; + }} + .formula-node:hover .formula-tooltip, + .formula-node:focus .formula-tooltip, + .formula-node:focus-within .formula-tooltip {{ + opacity: 1; + transform: translateX(-50%) translateY(0); + }} + .formula-children {{ + display: flex; + justify-content: center; + gap: 16px; + align-items: flex-start; + position: relative; + padding-top: 18px; + margin-top: 10px; + }} + .formula-children::before {{ + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 18px; + background: var(--line); + }} + .formula-children.has-multiple::after {{ + content: ""; + position: absolute; + top: 0; + left: 12%; + right: 12%; + height: 2px; + background: var(--line); + }} + .formula-branch {{ + position: relative; + display: flex; + flex-direction: column; + align-items: center; + }} + .formula-branch::before {{ + content: ""; + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 18px; + background: var(--line); + }} + .history-index-panel {{ + margin-top: 14px; + }} + .history-stack {{ + display: grid; + gap: 12px; + margin-top: 14px; + }} + .history-card {{ + border: 1px solid rgba(123, 83, 44, 0.12); + border-radius: 18px; + background: rgba(255, 253, 248, 0.92); + overflow: hidden; + box-shadow: 0 14px 24px rgba(66, 43, 17, 0.06); + }} + .history-card summary {{ + list-style: none; + cursor: pointer; + padding: 14px 16px; + }} + .history-card summary::-webkit-details-marker {{ + display: none; + }} + .history-card[open] summary {{ + border-bottom: 1px solid rgba(123, 83, 44, 0.12); + background: linear-gradient(180deg, rgba(245, 237, 224, 0.96), rgba(255, 253, 248, 0.96)); + }} + .history-summary {{ + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + }} + .history-summary h3 {{ + margin: 0 0 6px; + font-size: 0.94rem; + line-height: 1.35; + }} + .history-summary-side {{ + flex-shrink: 0; + }} + .history-body {{ + padding: 14px 16px 16px; + }} + .history-table {{ + min-width: 1120px; + table-layout: auto; + }} + .history-table td {{ + min-width: 120px; + }} + @media (max-width: 900px) {{ + body {{ font-size: 13px; }} + main {{ padding: 16px 12px 40px; }} + .panel-grid {{ grid-template-columns: 1fr; }} + .report-table {{ min-width: 680px; }} + .decision-doc-grid {{ grid-template-columns: 1fr; }} + .formula-tree {{ padding-top: 104px; }} + .tab-strip {{ + width: 100%; + justify-content: stretch; + }} + .tab-button {{ + flex: 1 1 0; + text-align: center; + }} + }} +
    -
    -

    T Cell HTML Report

    +
    +

    T Cell HTML Report

    T Cell Run Report

    Static analysis of observations, signals, transitions, memories, and optional decision traces. Generated at {escape(report['generated_at'])}

    @@ -1967,11 +3617,18 @@ def render_html(report: dict) -> str:

    Database

    {escape(report['sources']['db_path'])}

    Module Log

    {escape(report['sources']['log_path'])}

    Decision Trace

    {escape(report['sources']['trace_path'])}
    -
    -
    +
    + -
    -
    +
    + + +
    + +
    + +
    +

    Quick Summary

    {render_counter_cards(report)} @@ -2030,27 +3687,55 @@ def render_html(report: dict) -> str: {trace_section}
    -
    -

    Recent Observations

    -

    These rows come from the T Cell SQLite DB, so they remain available even when module log verbosity was low. Click a column header to sort.

    - {observation_table} -
    - - - - + + +""" + + +def state_class_name(label: str) -> str: + mapping = {value: STATE_CLASS[key] for key, value in STATE_LABELS.items()} + return mapping.get(label, "state-unknown") + + +def write_report(run_output_dir: Path, output_html: Path, args: argparse.Namespace) -> Path: + report = build_report_payload( + run_output_dir, + max_observations=args.max_observations, + max_log_lines=args.max_log_lines, + max_trace_rows=args.max_trace_rows, + ) + output_html.parent.mkdir(parents=True, exist_ok=True) + output_html.write_text(render_html(report), encoding="utf-8") + return output_html + + +def main() -> int: + args = parse_args() + run_output_dir = Path(args.run_output_dir).expanduser().resolve() + output_html = ( + Path(args.out).expanduser().resolve() + if args.out + else run_output_dir / "t_cell_report.html" + ) + report_path = write_report(run_output_dir, output_html, args) + print(f"Report written to: {report_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9f69b30ba43f7bfa04d986caecce8391322b77ab Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:37 +0000 Subject: [PATCH 0650/1100] feat: add offline HTML report generator for T Cell module analysis --- docs/t_cell_module.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index af8e2a444b..1ccf735416 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -418,6 +418,41 @@ Performance note: - trace mode performs extra observation lookups and extra file writes, so it should be treated as a verification feature, not the normal default path +### Offline HTML Report + +The module includes a separate offline report generator: + +```bash +./venv/bin/python modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ +``` + +By default it writes: + +```text +/t_cell_report.html +``` + +The report is static and self-contained. It reads the T Cell SQLite DB as the +primary source, then enriches the page with `t_cell.log` and +`t_cell_trace.jsonl` when those files exist. This means: + +- it still explains the run when `log_verbosity` is `1` +- it gains richer per-evidence detail when `log_verbosity` is `2` or `3` +- it gains threshold-by-threshold explanations when decision tracing is enabled + +The page focuses on the run itself, including: + +- total `PAMP` and `DAMP` observations +- evidence type mix +- extracted antigens and matched regexes +- current cells and their states +- transition reasons and state-path counts +- memories stored so far +- observation, transition, and trace timelines +- a sortable Recent Observations table at the bottom of the page +- a compact, collapsed configuration snapshot at the very end + Color mapping: - `0 - mature` -> cyan From b099779293abf13c0562930f11eb82129d58e46f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:43 +0000 Subject: [PATCH 0651/1100] feat: add instructions for offline HTML report generation in T Cell module --- modules/t_cell/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index 3473befe02..db0074120d 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -38,6 +38,28 @@ Artifacts: The configured trace path is always forced under the selected run output directory. - module DB: `/t_cell/t_cell.sqlite` +- offline HTML report: `/t_cell_report.html` + +## Local HTML Report + +Use the included offline report generator to build a static HTML page from a +completed or running Slips output directory: + +```bash +./venv/bin/python modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ +``` + +By default it writes: + +```text +output//t_cell_report.html +``` + +The report reads the T Cell SQLite DB first, then enriches the page with the +module log and decision trace when those files exist. That means it still gives +useful summaries when `log_verbosity` is `1` or `2`, and becomes more detailed +when verbosity `3` or decision tracing is enabled. See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, configuration, formulas, and DB schema. From 5e4b893c8ca93b16b870fb452d61863df0b50b1d Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:53 +0000 Subject: [PATCH 0652/1100] feat: add unit tests for T Cell report generation and HTML rendering --- .../modules/t_cell/test_analyze_t_cell.py | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 tests/unit/modules/t_cell/test_analyze_t_cell.py diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py new file mode 100644 index 0000000000..cecabf2fb6 --- /dev/null +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -0,0 +1,330 @@ +# SPDX-FileCopyrightText: 2026 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +from pathlib import Path +from unittest.mock import Mock + +from modules.t_cell.analyze_t_cell import build_report_payload, render_html +from slips_files.core.database.sqlite_db.t_cell_db import TCellStorage + + +def _build_storage(run_dir: Path) -> TCellStorage: + conf = Mock() + conf.t_cell_store_dir = Mock(return_value="output/t_cell") + conf.t_cell_persistent_store_dir = Mock(return_value="") + return TCellStorage(Mock(), conf, str(run_dir), 12345) + + +def _raw_evidence( + evidence_id: str, + evidence_type: str, + signal: str, + related_profile_ip: str, + attacker_ip: str, + victim_ip: str, + description: str, +) -> dict: + return { + "evidence_type": evidence_type, + "description": description, + "attacker": { + "direction": "SRC", + "ioc_type": "IP", + "value": attacker_ip, + }, + "victim": { + "direction": "DST", + "ioc_type": "IP", + "value": victim_ip, + }, + "profile": {"ip": related_profile_ip}, + "timewindow": {"number": 1}, + "uid": [], + "timestamp": "2026/03/21 09:22:37.000000+0000", + "interface": "eno1", + "id": evidence_id, + "confidence": 1.0, + "threat_level": "HIGH", + "evidence_signal": signal, + } + + +def test_build_report_payload_and_html(tmp_path): + run_dir = tmp_path / "run-output" + (run_dir / "metadata").mkdir(parents=True) + storage = _build_storage(run_dir) + + damp_observation_id = storage.insert_observation( + { + "evidence_id": "damp-1", + "evidence_type": "HTTP_TRAFFIC", + "evidence_signal": "DAMP", + "profile_ip": "2001:db8::5", + "timewindow_number": 1, + "timestamp": "2026/03/21 09:22:37.000000+0000", + "observed_at": 1000.0, + "confidence": 0.9, + "threat_level": "medium", + "threat_level_value": 0.5, + "interface": "eno1", + "uids": ["uid-damp-1"], + "antigen_count": 2, + "antigens": [ + {"regex_type": "dns_domain", "value": "rdap.db.ripe.net"}, + {"regex_type": "uri", "value": "/ip/5.161.194.92"}, + ], + "matched_regexes": [], + "raw_evidence": _raw_evidence( + "damp-1", + "HTTP_TRAFFIC", + "DAMP", + "2001:db8::5", + "2001:db8::5", + "2001:67c:2e8:22::c100:697", + "RDAP lookup over HTTP", + ), + } + ) + + pamp_observation_id = storage.insert_observation( + { + "evidence_id": "pamp-1", + "evidence_type": "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "evidence_signal": "PAMP", + "profile_ip": "203.0.113.90", + "timewindow_number": 2, + "timestamp": "2026/03/21 09:23:37.000000+0000", + "observed_at": 2000.0, + "confidence": 1.0, + "threat_level": "high", + "threat_level_value": 0.8, + "interface": "eno1", + "uids": ["uid-pamp-1"], + "antigen_count": 1, + "antigens": [ + {"regex_type": "dns_domain", "value": "bad.example.com"} + ], + "matched_regexes": [ + { + "regex_type": "dns_domain", + "value": "bad.example.com", + "regex_hash": "regex-hash-1", + "regex": r"^bad\.example\.com$", + "created_at": 1990.0, + "specificity": 1.0, + } + ], + "raw_evidence": _raw_evidence( + "pamp-1", + "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "PAMP", + "147.32.80.37", + "203.0.113.90", + "147.32.80.37", + "Known malicious domain", + ), + } + ) + + cell_key = "203.0.113.90|dns_domain|bad.example.com" + storage.upsert_cell( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "state": 5, + "state_name": "5 - memory", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": pamp_observation_id, + "last_evidence_id": "pamp-1", + "last_transition_at": 2000.3, + "last_co_stimulation": 0.91, + "last_effector_score": 0.33, + "last_memory_score": 0.78, + "context": {"novelty_score": 0, "recent_pressure": 0.42}, + "created_at": 2000.0, + "updated_at": 2000.3, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 0, + "to_state": 1, + "reason": "antigen_match", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"specificity": 1.0}, + "created_at": 2000.1, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 1, + "to_state": 3, + "reason": "co_stimulation_threshold_met", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"value": 0.91, "threshold": 0.65}, + "created_at": 2000.2, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 3, + "to_state": 5, + "reason": "context_memory", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"memory_score": 0.78, "memory_threshold": 0.60}, + "created_at": 2000.3, + } + ) + storage.upsert_memory( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "regex_hash": "regex-hash-1", + "regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "context": {"memory_score": 0.78, "recent_pressure": 0.42}, + "created_at": 2000.3, + "updated_at": 2000.3, + } + ) + + (run_dir / "metadata" / "slips.yaml").write_text( + "\n".join( + [ + "t_cell:", + " enabled: true", + " log_verbosity: 3", + " decision_trace_mode: transitions", + " co_stimulation_threshold: 0.65", + " effector_threshold: 0.70", + " memory_threshold: 0.60", + ] + ), + encoding="utf-8", + ) + (run_dir / "t_cell.log").write_text( + "\n".join( + [ + "T Cell module ready.", + "2026/03/21 09:22:37.597262 | action=antigens_extracted | evidence=HTTP_TRAFFIC | eid=damp-1 | signal=DAMP | profile=2001:db8::5 | responsible=2001:db8::5 | target=2001:67c:2e8:22::c100:697 | antigens=dns_domain:rdap.db.ripe.net, uri:/ip/5.161.194.92", + "2026/03/21 09:22:37.607926 | action=ignored_non_pamp | evidence=HTTP_TRAFFIC | eid=damp-1 | signal=DAMP | profile=2001:db8::5 | responsible=2001:db8::5 | target=2001:67c:2e8:22::c100:697", + "2026/03/21 09:23:37.607926 | action=memory_stored | state=5 - memory | evidence=THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN | eid=pamp-1 | signal=PAMP | profile=147.32.80.37 | responsible=203.0.113.90 | target=147.32.80.37 | cell=203.0.113.90|dns_domain|bad.example.com | regex=regex-hash-1 | value=bad.example.com", + ] + ), + encoding="utf-8", + ) + (run_dir / "t_cell_trace.jsonl").write_text( + "\n".join( + [ + json.dumps( + { + "ts": "2026/03/21 09:23:37.200000+0000", + "stage": "co_stimulation", + "action": "co_stimulation_threshold_met", + "from_state": "1 - antigen-recognized", + "to_state": "3 - activated", + "responsible_ip": "203.0.113.90", + "candidate": { + "regex_type": "dns_domain", + "value": "bad.example.com", + }, + "formula": { + "value": 0.91, + "threshold": 0.65, + "components": { + "related_pamps": {"count": 1}, + }, + }, + } + ), + json.dumps( + { + "ts": "2026/03/21 09:23:37.300000+0000", + "stage": "context", + "action": "context_memory", + "from_state": "3 - activated", + "to_state": "5 - memory", + "responsible_ip": "203.0.113.90", + "candidate": { + "regex_type": "dns_domain", + "value": "bad.example.com", + }, + "formula": { + "effector_score": 0.33, + "effector_threshold": 0.70, + "memory_score": 0.78, + "memory_threshold": 0.60, + }, + } + ), + ] + ), + encoding="utf-8", + ) + + payload = build_report_payload(run_dir, max_observations=50, max_log_lines=50, max_trace_rows=50) + + assert payload["totals"]["observations"] == 2 + assert payload["totals"]["signals"] == {"DAMP": 1, "PAMP": 1} + assert payload["totals"]["transitions"] == 3 + assert payload["totals"]["memories"] == 1 + assert payload["cell_states"] == {"5 - memory": 1} + assert payload["sources"]["trace_enabled"] is True + assert payload["trace"]["total_rows"] == 2 + assert payload["recent_observations"][0]["category"] == "PAMP with regex match" + assert any( + row["category"] == "DAMP with extracted antigens" + for row in payload["recent_observations"] + ) + assert payload["top_responsible_ips"][0]["label"] == "2001:db8::5" + + html = render_html(payload) + + assert "T Cell Report" in html + assert "T Cell Run Report" in html + assert "Run Findings" in html + assert "Quick Summary" in html + assert "Decision Trace" in html + assert "Module Log Tail" not in html + assert "data-sortable-table='recent-observations'" in html + assert "Click a column header to sort." in html + assert html.index("Recent Observations") < html.index("Run configuration snapshot") + assert "co_stimulation_threshold_met" in html + assert "context_memory" in html + assert "bad.example.com" in html + assert "DAMP with extracted antigens" in html + assert "PAMP with regex match" in html + + storage.close() From 9d2371521cdf404fb284c20da46a2d8f9c80d90f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:10 +0000 Subject: [PATCH 0653/1100] feat: add Mermaid state diagram for T Cell state machine and enhance report details --- docs/t_cell_module.md | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 1ccf735416..6800211579 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -93,6 +93,58 @@ The persisted states are: - `4 - effector` - `5 - memory` +Mermaid state diagram: + +```mermaid +stateDiagram-v2 + [*] --> S0 : new cell + + state "0 - mature" as S0 + state "1 - antigen-recognized" as S1 + state "2 - anergic" as S2 + state "3 - activated" as S3 + state "4 - effector" as S4 + state "5 - memory" as S5 + + S0 --> S1 : PAMP + antigen extracted\n+ accepted regex match + S0 --> S2 : PAMP + antigen extracted\n+ no regex match + S0 --> S0 : DAMP only or\nno antigen extracted + + S2 --> S0 : anergy TTL expired + + S1 --> S3 : co-stimulation >= threshold\nwithin 1 Slips TW + S1 --> S1 : re-evaluate on later evidence\nwhile below threshold + S1 --> S2 : co-stimulation timeout\nafter 1 Slips TW + + S3 --> S4 : context says novel + intense + S3 --> S5 : context says familiar + cooling down + S3 --> S3 : re-evaluate on later evidence\nwhile undecided + S3 --> S0 : context timeout\nafter 1 Slips TW + + S5 --> S5 : later matching evidence retained + S4 --> S4 : repeated hits gated by\neffector cooldown + + note right of S0 + DAMP observations are stored as danger signals. + They do not perform antigen recognition + and do not create a new cell by themselves. + end note + + note right of S1 + Co-stimulation combines: + current PAMP confidence + related PAMP count + weighted PAMP+DAMP danger + for the same responsible IP. + end note + + note right of S3 + Context uses the same mixed pressure model + to decide whether to contain now + or store memory for later. + end note +``` + The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. @@ -445,12 +497,14 @@ The page focuses on the run itself, including: - total `PAMP` and `DAMP` observations - evidence type mix +- a rendered T-cell state-machine graph with per-state and per-transition counts - extracted antigens and matched regexes - current cells and their states - transition reasons and state-path counts - memories stored so far - observation, transition, and trace timelines - a sortable Recent Observations table at the bottom of the page +- a sortable Transitions table that defaults to grouping rows by T cell - a compact, collapsed configuration snapshot at the very end Color mapping: From 3b2902f70587d875ba42bf2bb6dea5329ab4d964 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:17 +0000 Subject: [PATCH 0654/1100] feat: enhance LLMBackend to support configurable HTTP connection pool size --- modules/llm/llm.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/llm/llm.py b/modules/llm/llm.py index 5f71e2a01a..7982c7404b 100644 --- a/modules/llm/llm.py +++ b/modules/llm/llm.py @@ -114,11 +114,12 @@ def _resolve_api_key(data: dict) -> str | None: class LLMBackend: - def __init__(self, config: LLMBackendConfig): + def __init__(self, config: LLMBackendConfig, pool_maxsize: int = 2): self.config = config self.http = urllib3.PoolManager( cert_reqs="CERT_REQUIRED", ca_certs=certifi.where(), + maxsize=max(2, int(pool_maxsize)), ) def generate(self, request: dict) -> dict: @@ -345,11 +346,14 @@ def read_configuration(self): self.failed_backends[alias] = str(exc) def _create_backend(self, config: LLMBackendConfig) -> LLMBackend: + # Keep the reusable HTTP connection pool comfortably above the + # worker concurrency so busy runs do not spam pool-discard warnings. + pool_maxsize = max(2, self.worker_threads * 2) if config.provider == "openai": - return OpenAIBackend(config) + return OpenAIBackend(config, pool_maxsize=pool_maxsize) if config.provider == "anthropic": - return AnthropicBackend(config) - return OllamaBackend(config) + return AnthropicBackend(config, pool_maxsize=pool_maxsize) + return OllamaBackend(config, pool_maxsize=pool_maxsize) @staticmethod def _empty_available_backends_registry() -> dict: From 68e7b57f942767972680ff4ce68f52640f8e7d21 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:24 +0000 Subject: [PATCH 0655/1100] feat: add sortable transition table and state machine graph to T Cell report --- modules/t_cell/analyze_t_cell.py | 288 ++++++++++++++++++++++++++++--- 1 file changed, 261 insertions(+), 27 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index 2acf246ccc..55fb3c95e6 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -709,12 +709,22 @@ def build_report_payload( "evidence_id": transition["evidence_id"], "from_state": from_label, "to_state": to_label, + "from_state_order": transition.get("from_state", -1), + "to_state_order": transition.get("to_state", -1), "reason": transition["reason"], "matched_value": transition.get("matched_value") or "", "scores": transition.get("scores") or {}, } ) - recent_transitions.sort(key=lambda item: item["ts"], reverse=True) + recent_transitions.sort( + key=lambda item: ( + item["cell_key"].lower(), + float(item["ts"]), + int(item["from_state_order"]), + int(item["to_state_order"]), + item["evidence_id"], + ) + ) current_state_counts = Counter() recent_cells = [] @@ -995,6 +1005,67 @@ def render_sortable_observation_table(rows: list[dict]) -> str: ) +def render_sortable_transition_table(rows: list[dict]) -> str: + if not rows: + return '

    No state transitions were recorded.

    ' + + columns = [ + "When", + "Path", + "Reason", + "Responsible", + "T Cell", + "Evidence", + "Scores", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + score_summary = ", ".join( + f"{key}={value}" for key, value in sorted((row["scores"] or {}).items()) + ) or "n/a" + cells = [ + (escape(row["wall"]), row["ts"]), + ( + f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " + f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}", + f"{row['from_state_order']:02d}->{row['to_state_order']:02d}", + ), + (escape(row["reason"]), row["reason"]), + (escape(row["responsible_ip"]), row["responsible_ip"]), + (escape(shorten(row["cell_key"], 54)), row["cell_key"]), + (escape(shorten(row["evidence_id"], 20)), row["evidence_id"]), + ( + f"
    show
    {render_pretty_json(row['scores'])}
    ", + score_summary, + ), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
    " + "" + f"{head}{body}
    " + ) + + def render_svg_timeline(title: str, timeline: dict, series_order: list[str], color_map: dict[str, str]) -> str: if not timeline: return ( @@ -1062,6 +1133,189 @@ def render_svg_timeline(title: str, timeline: dict, series_order: list[str], col """ +def hex_to_rgba(hex_color: str, alpha: float) -> str: + color = hex_color.lstrip("#") + if len(color) != 6: + return f"rgba(31, 41, 55, {alpha})" + red = int(color[0:2], 16) + green = int(color[2:4], 16) + blue = int(color[4:6], 16) + return f"rgba({red}, {green}, {blue}, {alpha})" + + +def render_state_machine_graph(report: dict) -> str: + node_layout = { + 0: {"x": 40, "y": 122}, + 1: {"x": 320, "y": 44}, + 2: {"x": 320, "y": 244}, + 3: {"x": 600, "y": 122}, + 4: {"x": 880, "y": 30}, + 5: {"x": 880, "y": 214}, + } + node_width = 210 + node_height = 68 + transition_counts = { + row["label"]: row["count"] for row in report.get("transition_paths", []) + } + current_state_counts = report.get("cell_states", {}) + + edges = [ + { + "from": 0, + "to": 1, + "trigger": "regex match", + "path": "M 250 156 C 275 156, 286 120, 320 104", + "label_x": 272, + "label_y": 116, + }, + { + "from": 0, + "to": 2, + "trigger": "no regex", + "path": "M 250 156 C 275 156, 286 286, 320 278", + "label_x": 268, + "label_y": 252, + }, + { + "from": 2, + "to": 0, + "trigger": "anergy TTL", + "path": "M 320 306 C 248 338, 178 322, 146 190", + "label_x": 182, + "label_y": 330, + }, + { + "from": 1, + "to": 1, + "trigger": "wait", + "path": "M 392 44 C 350 4, 502 4, 460 44", + "label_x": 426, + "label_y": 12, + }, + { + "from": 1, + "to": 3, + "trigger": "co-stimulation", + "path": "M 530 78 L 600 156", + "label_x": 542, + "label_y": 94, + }, + { + "from": 1, + "to": 2, + "trigger": "timeout", + "path": "M 425 112 L 425 244", + "label_x": 438, + "label_y": 184, + }, + { + "from": 3, + "to": 3, + "trigger": "wait", + "path": "M 672 122 C 630 82, 782 82, 740 122", + "label_x": 706, + "label_y": 90, + }, + { + "from": 3, + "to": 4, + "trigger": "contain", + "path": "M 810 144 L 880 86", + "label_x": 828, + "label_y": 112, + }, + { + "from": 3, + "to": 5, + "trigger": "remember", + "path": "M 810 168 L 880 248", + "label_x": 824, + "label_y": 214, + }, + { + "from": 3, + "to": 0, + "trigger": "context timeout", + "path": "M 600 156 C 536 236, 286 236, 250 156", + "label_x": 430, + "label_y": 260, + }, + { + "from": 4, + "to": 4, + "trigger": "cooldown", + "path": "M 952 30 C 914 -8, 1088 -8, 1050 30", + "label_x": 1000, + "label_y": 2, + }, + { + "from": 5, + "to": 5, + "trigger": "retained", + "path": "M 952 282 C 914 320, 1088 320, 1050 282", + "label_x": 998, + "label_y": 334, + }, + ] + + node_svg = [] + for state_id, label in STATE_LABELS.items(): + node = node_layout[state_id] + color = STATE_COLORS[state_class(state_id)] + count = current_state_counts.get(label, 0) + node_svg.append( + f""" + + + {escape(label)} + current cells: {count} + + """ + ) + + edge_svg = [] + for edge in edges: + from_label = STATE_LABELS[edge["from"]] + to_label = STATE_LABELS[edge["to"]] + path_key = f"{from_label} -> {to_label}" + count = int(transition_counts.get(path_key, 0)) + active = count > 0 + stroke = STATE_COLORS[state_class(edge["to"])] + edge_svg.append( + f""" + + + + {escape(edge['trigger'])} · {count} + + + """ + ) + + return f""" +
    +
    +

    T Cell State Machine

    +

    Node badges show current cells in each state. Arrow labels show how many times each transition happened in this run.

    +
    + + + + + + + + {''.join(edge_svg)} + {''.join(node_svg)} + +
    + """ + + def render_pretty_json(value: Any) -> str: return escape(json.dumps(value, indent=2, sort_keys=True)) @@ -1116,32 +1370,8 @@ def render_html(report: dict) -> str: report["recent_observations"] ) - transition_table = render_simple_table( - [ - "When", - "Path", - "Reason", - "Responsible", - "Cell", - "Evidence", - "Scores", - ], - [ - { - "When": escape(row["wall"]), - "Path": ( - f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " - f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}" - ), - "Reason": escape(row["reason"]), - "Responsible": escape(row["responsible_ip"]), - "Cell": escape(shorten(row["cell_key"], 54)), - "Evidence": escape(shorten(row["evidence_id"], 20)), - "Scores": f"
    show
    {render_pretty_json(row['scores'])}
    ", - } - for row in report["recent_transitions"] - ], - "No state transitions were recorded.", + transition_table = render_sortable_transition_table( + report["recent_transitions"] ) cell_table = render_simple_table( @@ -1332,6 +1562,7 @@ def render_html(report: dict) -> str: ["co-stimulation", "context"], TRACE_STAGE_COLORS, ) + state_machine_graph = render_state_machine_graph(report) return f""" @@ -1668,6 +1899,8 @@ def render_html(report: dict) -> str: {trace_timeline}
    + {state_machine_graph} +

    Signals

    @@ -1685,6 +1918,7 @@ def render_html(report: dict) -> str:

    Transitions

    +

    Click a column header to sort. Default order groups rows by T cell so each cell's path stays together.

    {transition_table}
    From a288b73029c8c4cf6be75ea058476fe21aa74c91 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:30 +0000 Subject: [PATCH 0656/1100] feat: add state machine diagram for T Cell module in README --- modules/t_cell/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index db0074120d..c8bcb53322 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -31,6 +31,31 @@ Main behavior: - containment reuses the existing `new_blocking` payload shape - all T Cell state is stored in its own SQLite DB and log file +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> S0 + + state "0 - mature" as S0 + state "1 - antigen-recognized" as S1 + state "2 - anergic" as S2 + state "3 - activated" as S3 + state "4 - effector" as S4 + state "5 - memory" as S5 + + S0 --> S1 : PAMP + antigen + regex match + S0 --> S2 : PAMP + antigen + no regex match + S0 --> S0 : DAMP only or no antigen + S2 --> S0 : anergy TTL expired + S1 --> S3 : co-stimulation threshold met + S1 --> S2 : co-stimulation timeout + S3 --> S4 : context -> contain + S3 --> S5 : context -> remember + S3 --> S0 : context timeout + S5 --> S5 : later matching evidence retained +``` + Artifacts: - module log: `output/t_cell.log` From 063d77e11f98778f8db1c1b44f31a49928936a33 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:35 +0000 Subject: [PATCH 0657/1100] feat: add test for LLMBackend pool size scaling with worker threads --- tests/unit/modules/llm/test_llm.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/modules/llm/test_llm.py b/tests/unit/modules/llm/test_llm.py index 6fff9eb2bb..0391b05666 100644 --- a/tests/unit/modules/llm/test_llm.py +++ b/tests/unit/modules/llm/test_llm.py @@ -277,3 +277,21 @@ def test_ollama_backend_parses_response(): assert response["usage"]["input_tokens"] == 9 assert response["usage"]["output_tokens"] == 11 assert response["usage"]["total_tokens"] == 20 + + +def test_llm_backend_pool_size_scales_with_worker_threads(): + llm = ModuleFactory().create_llm_obj() + llm.worker_threads = 3 + config = LLMBackendConfig.from_dict( + "local_qwen", + { + "provider": "ollama", + "model": "qwen2.5:3b", + "base_url": "http://127.0.0.1:11434", + }, + ) + + with patch("modules.llm.llm.urllib3.PoolManager") as mock_pool: + llm._create_backend(config) + + assert mock_pool.call_args.kwargs["maxsize"] == 6 From 386782eeecd6f8131de8e84443d439b4be3087f5 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:43 +0000 Subject: [PATCH 0658/1100] feat: enhance report HTML output with T Cell state machine details and sortable transitions --- tests/unit/modules/t_cell/test_analyze_t_cell.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index cecabf2fb6..454a5fb29a 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -317,8 +317,14 @@ def test_build_report_payload_and_html(tmp_path): assert "Run Findings" in html assert "Quick Summary" in html assert "Decision Trace" in html + assert "T Cell State Machine" in html + assert "regex match" in html + assert "current cells: 1" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html + assert "data-sortable-table='recent-transitions'" in html + assert "data-default-sort-column='4'" in html + assert "Default order groups rows by T cell" in html assert "Click a column header to sort." in html assert html.index("Recent Observations") < html.index("Run configuration snapshot") assert "co_stimulation_threshold_met" in html From cca0070864521aa8be74e64d42d3d03f3478464a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:09 +0000 Subject: [PATCH 0659/1100] fix: clarify DAMP evidence handling in T Cell module description --- docs/evidence_signals.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md index 544898b8a0..12b9cbbca8 100644 --- a/docs/evidence_signals.md +++ b/docs/evidence_signals.md @@ -6,7 +6,9 @@ The `T Cell` module consumes this same central field and only activates its state machine for antigen recognition from `PAMP` evidence. `DAMP` evidence is still stored by the module as an observation and contributes to the danger pressure used in T-cell co-stimulation and context calculations for the same -responsible IP, but it does not create cells or perform regex matching. See +responsible IP, and each new `DAMP` also reevaluates cells that are already +waiting on that responsible IP. `DAMP` does not create cells or perform regex +matching by itself. See [T Cell Module](t_cell_module.md) for the responder details. The supported values are: From ad391272582d8e5675b2ea3e89102e96f8f5b0b4 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:15 +0000 Subject: [PATCH 0660/1100] feat: add link to T Cell offline report generation in README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a14216beec..218d6addf5 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,9 @@ We appreciate your contributions and thank you for helping to improve Slips! T Cell design and configuration: [docs/t_cell_module.md](docs/t_cell_module.md) +T Cell offline report generation and interpretation: +[docs/t_cell_module.md#offline-html-report](docs/t_cell_module.md#offline-html-report) + [Code docs](https://stratospherelinuxips.readthedocs.io/en/develop/code_documentation.html ) --- From 9571f6e30beedc0eb804c0ef142e759134f76045 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:28 +0000 Subject: [PATCH 0661/1100] feat: enhance T Cell module documentation with DAMP handling and waiting states --- docs/t_cell_module.md | 88 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 6800211579..ab714f10be 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -6,7 +6,8 @@ accepted RegexGenerator regex corpus, and then escalates through a small state machine until it either becomes tolerant, publishes a containment request, or stores a memory snapshot for later reuse. `DAMP` observations do not perform antigen recognition, but they do raise the danger pressure used later in -co-stimulation and context decisions. +co-stimulation and context decisions and they now trigger reevaluation of +already waiting cells for the same responsible IP. The module is started by the normal Slips module loader and is enabled by default through `t_cell.enabled: true`. @@ -23,8 +24,8 @@ modules: 4. It matches those values against accepted regexes already stored by `RegexGenerator`. 5. It stores `DAMP` observations as responsible-IP danger signals and folds - them into co-stimulation and context pressure for later `PAMP` - reevaluations. + them into co-stimulation and context pressure, and `DAMP` arrivals also + trigger reevaluation of cells that are already waiting. 6. It computes co-stimulation and context scores. 7. It either becomes tolerant, activates, requests blocking, or stores memory. @@ -93,6 +94,16 @@ The persisted states are: - `4 - effector` - `5 - memory` +States `1 - antigen-recognized` and `3 - activated` can also carry an +explicit waiting substatus in the stored cell context: + +- `1 - antigen-recognized (waiting for co-stimulation)` +- `3 - activated (waiting for context)` + +This does not create new state numbers. It is an explicit runtime marker that +the cell is still in state `1` or `3`, but is currently waiting for the next +reevaluation. + Mermaid state diagram: ```mermaid @@ -113,12 +124,12 @@ stateDiagram-v2 S2 --> S0 : anergy TTL expired S1 --> S3 : co-stimulation >= threshold\nwithin 1 Slips TW - S1 --> S1 : re-evaluate on later evidence\nwhile below threshold + S1 --> S1 : re-evaluate on later PAMP or DAMP\nwhile below threshold S1 --> S2 : co-stimulation timeout\nafter 1 Slips TW S3 --> S4 : context says novel + intense S3 --> S5 : context says familiar + cooling down - S3 --> S3 : re-evaluate on later evidence\nwhile undecided + S3 --> S3 : re-evaluate on later PAMP or DAMP\nwhile undecided S3 --> S0 : context timeout\nafter 1 Slips TW S5 --> S5 : later matching evidence retained @@ -127,7 +138,8 @@ stateDiagram-v2 note right of S0 DAMP observations are stored as danger signals. They do not perform antigen recognition - and do not create a new cell by themselves. + and do not create a new cell by themselves, + but they do re-check waiting cells. end note note right of S1 @@ -149,11 +161,13 @@ The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. 2. The module stores one observation row in its own SQLite DB. -3. If the evidence signal is not `PAMP`, the module logs `ignored_non_pamp` - and stops for that evidence after storing the observation. -4. Stored `DAMP` observations do not create or match cells, but they are kept - as danger inputs and are included in the next co-stimulation or context - evaluation for the same responsible IP. +3. If the evidence signal is `DAMP`, the module stores the observation, + reevaluates any waiting cells for the same responsible IP, logs + `damp_reverification`, and does not attempt antigen recognition from that + evidence. +4. If the evidence signal is neither `PAMP` nor `DAMP`, the module logs + `ignored_non_pamp` and stops for that evidence after storing the + observation. 5. If no structured antigen can be extracted, the module logs `no_antigen_extracted` and stops for that evidence. 6. For each antigen candidate, the module loads or creates the cell in @@ -166,11 +180,13 @@ The runtime flow is: a new `anergic_until`. 10. If a regex matches, the cell goes `0 -> 1` and stores the chosen regex metadata. -11. The module computes co-stimulation from the current `PAMP`, related - `PAMP`s, and stored `DAMP` danger pressure for the same responsible IP. +11. The module computes co-stimulation from the recognized `PAMP` + confidence, related `PAMP`s, and stored `DAMP` danger pressure for the + same responsible IP. 12. If co-stimulation crosses the configured threshold, the cell goes `1 -> 3`. 13. If co-stimulation stays below threshold, the cell can wait in - `1 - antigen-recognized` for at most one configured Slips time window. + `1 - antigen-recognized` for at most one configured Slips time window, + with the cell explicitly marked as waiting for co-stimulation. 14. If that one-time-window wait expires without enough co-stimulation, the cell goes `1 -> 2 - anergic`. 15. In state `3`, the module computes context signals from the same mixed @@ -182,6 +198,11 @@ The runtime flow is: 18. If state `3` cannot decide effector or memory within one configured Slips time window, the cell goes `3 -> 0 - mature`. +Both waiting states are reevaluated on later matching `PAMP`s and on later +`DAMP` observations for the same responsible IP. `DAMP` still does not create +or match a new cell by itself; it only re-checks cells that already exist and +are waiting. + State `4` publishes the existing `new_blocking` payload for the responsible IP when blocking support is present. If blocking or ARP poisoning modules are not running, the module can simulate the effector decision and log the exact @@ -485,6 +506,9 @@ By default it writes: /t_cell_report.html ``` +You can then open that HTML file directly in any browser. If you want a +different output filename, pass `--out `. + The report is static and self-contained. It reads the T Cell SQLite DB as the primary source, then enriches the page with `t_cell.log` and `t_cell_trace.jsonl` when those files exist. This means: @@ -493,6 +517,10 @@ primary source, then enriches the page with `t_cell.log` and - it gains richer per-evidence detail when `log_verbosity` is `2` or `3` - it gains threshold-by-threshold explanations when decision tracing is enabled +Example report screenshot from a real run: + +![T Cell HTML report overview](images/t_cell/t_cell_report_overview.png) + The page focuses on the run itself, including: - total `PAMP` and `DAMP` observations @@ -507,6 +535,38 @@ The page focuses on the run itself, including: - a sortable Transitions table that defaults to grouping rows by T cell - a compact, collapsed configuration snapshot at the very end +How to read the report: + +- **Quick Summary** and **Run Findings** tell you first whether the module saw + mostly `PAMP` or `DAMP`, whether cells were created at all, and whether the + run stalled because no supported antigen could be extracted. +- **Observation / Transition timelines** show when pressure and state changes + happened over time. This is the fastest way to see whether the module was + mostly idle, mostly collecting danger, or actively moving cells. +- **T Cell State Machine** overlays the abstract state machine with run data: + each node shows how many cells are currently in that state, and each arrow + shows how many times that transition happened in the run. +- **Signals**, **Evidence Types**, and the top-* panels show what fed the + danger model: which evidence classes dominated, which responsible IPs or + targets were involved most often, and which antigens or unmatched `PAMP` + values kept appearing. +- **Transitions** is the per-cell transition history. It is sortable and + defaults to grouping rows by T cell, so you can read one cell's path from + `0 - mature` onward without manually regrouping the table. +- **Current Cells** shows the cells that still exist now, their current state, + any explicit waiting substatus such as `waiting for co-stimulation` or + `waiting for context`, and the latest co-stimulation / effector / memory + scores that were stored on the cell. +- **Stored Memories** shows which cells have already reached + `5 - memory`, along with the saved context snapshot that will be reused + later. +- **Decision Trace** is the threshold-audit section. When enabled, it is where + you verify why a threshold passed by checking the weighted formula terms and + contributing evidence IDs. +- **Recent Observations** stays at the bottom as the raw sortable evidence + audit table. It is the best section to correlate what Slips generated with + what T Cell actually received and stored. + Color mapping: - `0 - mature` -> cyan From 887c7dbca4ff9fb6cdb23b583d6009f6317e843e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:38 +0000 Subject: [PATCH 0662/1100] feat: add waiting state handling and sortable cell table to T Cell report --- modules/t_cell/analyze_t_cell.py | 166 +++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 41 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index 55fb3c95e6..86bf52cd86 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -58,6 +58,10 @@ } SIGNAL_COLORS = {"PAMP": "#c2410c", "DAMP": "#0369a1"} TRACE_STAGE_COLORS = {"co_stimulation": "#b45309", "context": "#7c3aed"} +WAITING_LABELS = { + "co_stimulation": "waiting for co-stimulation", + "context": "waiting for context", +} def parse_args() -> argparse.Namespace: @@ -155,6 +159,19 @@ def state_class(state: int | None) -> str: return STATE_CLASS.get(state, "state-unknown") +def cell_waiting_label(cell: dict | None) -> str: + context = (cell or {}).get("context") or {} + return WAITING_LABELS.get(context.get("waiting_for"), "") + + +def display_cell_state(cell: dict) -> str: + label = state_label(cell.get("state")) + waiting_label = cell_waiting_label(cell) + if waiting_label: + return f"{label} ({waiting_label})" + return label + + def shorten(value: Any, limit: int = 96) -> str: text = str(value or "") if len(text) <= limit: @@ -738,6 +755,7 @@ def build_report_payload( "cell_key": cell["cell_key"], "responsible_ip": cell["responsible_ip"], "state": label, + "state_display": display_cell_state(cell), "state_class": state_class(cell["state"]), "regex_type": cell["regex_type"], "antigen_value": cell["antigen_value"], @@ -746,6 +764,7 @@ def build_report_payload( "last_effector_score": cell.get("last_effector_score"), "last_memory_score": cell.get("last_memory_score"), "last_evidence_id": cell.get("last_evidence_id") or "", + "waiting_label": cell_waiting_label(cell), } ) recent_cells.sort(key=lambda item: item["ts"], reverse=True) @@ -947,6 +966,90 @@ def render_simple_table(columns: list[str], rows: list[dict], empty_text: str) - ) +def render_sortable_cell_table(rows: list[dict]) -> str: + if not rows: + return '

    No cells are stored.

    ' + + columns = [ + "Updated", + "State", + "Responsible", + "T Cell", + "Antigen", + "Matched value", + "Scores", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + score_parts = [ + f"co={format_float(row['last_co_stimulation'])}" + if row["last_co_stimulation"] is not None + else "", + f"eff={format_float(row['last_effector_score'])}" + if row["last_effector_score"] is not None + else "", + f"mem={format_float(row['last_memory_score'])}" + if row["last_memory_score"] is not None + else "", + ] + score_summary = ", ".join(part for part in score_parts if part) or "n/a" + waiting_html = "" + if row["waiting_label"]: + waiting_html = ( + f"
    {escape(row['waiting_label'])}
    " + ) + cells = [ + (escape(row["wall"]), row["ts"]), + ( + "
    " + f"{render_badge(row['state'], row['state_class'])}" + f"{waiting_html}" + "
    ", + row["state"], + ), + (escape(row["responsible_ip"]), row["responsible_ip"]), + ( + f"
    {escape(shorten(row['cell_key'], 72))}
    ", + row["cell_key"], + ), + ( + f"
    {escape(row['regex_type'])}:" + f"{escape(shorten(row['antigen_value'], 52))}
    ", + f"{row['regex_type']}:{row['antigen_value']}", + ), + ( + f"
    {escape(shorten(row['matched_value'], 52))}
    ", + row["matched_value"], + ), + (escape(score_summary), score_summary), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
    " + "" + f"{head}{body}
    " + ) + + def render_sortable_observation_table(rows: list[dict]) -> str: if not rows: return '

    No observations available.

    ' @@ -1374,47 +1477,7 @@ def render_html(report: dict) -> str: report["recent_transitions"] ) - cell_table = render_simple_table( - [ - "Updated", - "State", - "Responsible", - "Cell", - "Antigen", - "Matched value", - "Scores", - ], - [ - { - "Updated": escape(row["wall"]), - "State": render_badge(row["state"], row["state_class"]), - "Responsible": escape(row["responsible_ip"]), - "Cell": escape(shorten(row["cell_key"], 56)), - "Antigen": escape(f"{row['regex_type']}:{shorten(row['antigen_value'], 40)}"), - "Matched value": escape(shorten(row["matched_value"], 48)), - "Scores": escape( - ", ".join( - part - for part in [ - f"co={format_float(row['last_co_stimulation'])}" - if row["last_co_stimulation"] is not None - else "", - f"eff={format_float(row['last_effector_score'])}" - if row["last_effector_score"] is not None - else "", - f"mem={format_float(row['last_memory_score'])}" - if row["last_memory_score"] is not None - else "", - ] - if part - ) - or "n/a" - ), - } - for row in report["recent_cells"] - ], - "No cells are stored.", - ) + cell_table = render_sortable_cell_table(report["recent_cells"]) memory_table = render_simple_table( ["Updated", "Responsible", "Cell", "Regex", "Matched value", "Context"], @@ -1755,6 +1818,26 @@ def render_html(report: dict) -> str: .report-table tr:last-child td {{ border-bottom: none; }} + .cells-table {{ + min-width: 900px; + table-layout: auto; + }} + .cell-state-stack {{ + display: grid; + gap: 4px; + min-width: 0; + align-items: start; + }} + .cell-substate {{ + color: var(--muted); + font-size: 0.68rem; + line-height: 1.2; + }} + .cell-key {{ + line-height: 1.25; + overflow-wrap: anywhere; + word-break: break-word; + }} .sort-button {{ display: inline-flex; align-items: center; @@ -1925,6 +2008,7 @@ def render_html(report: dict) -> str:

    Current Cells

    +

    Click a column header to sort. Waiting cells keep the main state badge and show the wait condition underneath.

    {cell_table}
    From d950c47eed51139b6365714345a16ee7b16da5eb Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:45 +0000 Subject: [PATCH 0663/1100] feat: enhance README with detailed T Cell behavior and report insights --- modules/t_cell/README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index c8bcb53322..144c9a21a4 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -18,14 +18,16 @@ Main behavior: - `evidence.profile.ip` is the related host context, while containment and T-cell ownership use the evidence's responsible IP - stored `DAMP` observations raise the danger pressure used by - co-stimulation and context for the same responsible IP + co-stimulation and context for the same responsible IP, and each new DAMP + reevaluates waiting cells on that responsible IP - optional decision tracing writes a separate JSONL audit file showing which evidence IDs contributed to threshold calculations - co-stimulation and context scores decide whether the cell becomes tolerant, activates, requests containment, or stores memory - state `1 - antigen-recognized` and state `3 - activated` can each wait for at most one configured Slips time window before timing out to `2 - anergic` - or `0 - mature` + or `0 - mature`; waiting cells are explicitly marked as + `waiting for co-stimulation` or `waiting for context` - once a cell reaches `5 - memory`, later matching evidence keeps it in memory without emitting repeated `memory_stored` actions - containment reuses the existing `new_blocking` payload shape @@ -49,9 +51,11 @@ stateDiagram-v2 S0 --> S0 : DAMP only or no antigen S2 --> S0 : anergy TTL expired S1 --> S3 : co-stimulation threshold met + S1 --> S1 : later PAMP or DAMP re-check S1 --> S2 : co-stimulation timeout S3 --> S4 : context -> contain S3 --> S5 : context -> remember + S3 --> S3 : later PAMP or DAMP re-check S3 --> S0 : context timeout S5 --> S5 : later matching evidence retained ``` @@ -81,10 +85,23 @@ By default it writes: output//t_cell_report.html ``` +Open that HTML file locally in a browser. If you want a different filename, +pass `--out `. + The report reads the T Cell SQLite DB first, then enriches the page with the module log and decision trace when those files exist. That means it still gives useful summaries when `log_verbosity` is `1` or `2`, and becomes more detailed when verbosity `3` or decision tracing is enabled. +What the report tells you: + +- whether the run was dominated by `PAMP`, `DAMP`, or both +- which evidence types, responsible IPs, targets, and antigens drove the run +- which T-cell state transitions happened and how many times +- which cells are currently waiting, activated, anergic, effector, or memory +- why thresholds were crossed when decision tracing was enabled +- which raw observations reached the T Cell module, even when log verbosity was + low + See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, configuration, formulas, and DB schema. From edd32f632e3645c26fdcbf2f87fc755fe5e124d9 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:51 +0000 Subject: [PATCH 0664/1100] feat: implement waiting state handling and DAMP reevaluation in T Cell module --- modules/t_cell/t_cell.py | 560 ++++++++++++++++++++++++++++++--------- 1 file changed, 441 insertions(+), 119 deletions(-) diff --git a/modules/t_cell/t_cell.py b/modules/t_cell/t_cell.py index ded0c8fc56..7b9f420a09 100644 --- a/modules/t_cell/t_cell.py +++ b/modules/t_cell/t_cell.py @@ -57,6 +57,13 @@ TRACE_MODE_OFF = 0 TRACE_MODE_TRANSITIONS = 1 TRACE_MODE_ALL = 2 +CONTEXT_REMOVE = object() +WAITING_CO_STIMULATION = "co_stimulation" +WAITING_CONTEXT = "context" +WAITING_LABELS = { + WAITING_CO_STIMULATION: "waiting for co-stimulation", + WAITING_CONTEXT: "waiting for context", +} @dataclass(frozen=True) @@ -264,6 +271,27 @@ def _process_evidence_message(self, message: dict): ) matched_regexes = [] + if evidence.evidence_signal == EvidenceSignal.DAMP: + reevaluated_count = self._reevaluate_waiting_cells( + evidence=evidence, + observation_id=observation_id, + responsible_ip=responsible_ip, + now=now, + ) + self._log_event( + action="damp_reverification", + state=None, + evidence=evidence, + metrics={"reevaluated_cells": reevaluated_count}, + details=( + "stored DAMP danger and rechecked waiting cells for this " + "responsible IP" + ), + verbosity=LOG_VERBOSITY_DECISIONS, + ) + self._prune_observations(now) + return + if evidence.evidence_signal != EvidenceSignal.PAMP: self._log_event( action="ignored_non_pamp", @@ -364,10 +392,12 @@ def _process_candidate( now, last_observation_id=observation_id, last_evidence_id=evidence.id, - context={ - "reason": "no_regex_match_after_activation", - "observation_id": observation_id, - }, + ) + self._update_cell_context( + cell, + now, + reason="no_regex_match_after_activation", + observation_id=observation_id, ) self._log_event( action="no_regex_match", @@ -408,16 +438,348 @@ def _process_candidate( now, **match_updates, ) + cell = self._remember_match_context( + cell, + now, + observation_id, + evidence.id, + match, + ) if cell["state"] == STATE_MEMORY: - self._update_cell( + self._update_cell_context( cell, now, - context={ - "reason": "memory_retained", - "observation_id": observation_id, + reason="memory_retained", + observation_id=observation_id, + matched_regex_hash=match.regex_hash, + ) + self._log_event( + action="memory_retained", + state=STATE_MEMORY, + evidence=evidence, + cell=cell, + match=match, + details=( + "memory already exists for this cell; keeping the memory " + "state without storing a new memory event" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + return match + + return self._advance_cell_with_match( + cell=cell, + evidence=evidence, + observation_id=observation_id, + candidate=candidate, + match=match, + now=now, + responsible_ip=responsible_ip, + reference_observation_id=observation_id, + ) + + def _get_or_create_cell( + self, profile_ip: str, regex_type: str, antigen_value: str, now: float + ) -> dict: + cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) + cell = self.storage.get_cell(cell_key) + if cell: + return cell + + return { + "cell_key": cell_key, + "profile_ip": profile_ip, + "regex_type": regex_type, + "antigen_value": antigen_value, + "state": STATE_MATURE, + "state_name": STATE_INFO[STATE_MATURE]["label"], + "matched_regex_hash": None, + "matched_regex": None, + "matched_value": None, + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": None, + "last_evidence_id": None, + "last_transition_at": None, + "last_co_stimulation": None, + "last_effector_score": None, + "last_memory_score": None, + "context": {}, + "created_at": now, + "updated_at": now, + } + + def _transition_cell( + self, + cell: dict, + to_state: int, + reason: str, + evidence, + observation_id: int, + now: float, + match: RegexMatch | None = None, + scores: dict | None = None, + extra_updates: dict | None = None, + ) -> dict: + from_state = cell["state"] + updates = { + "state": to_state, + "state_name": STATE_INFO[to_state]["label"], + "last_observation_id": observation_id, + "last_evidence_id": evidence.id, + "last_transition_at": now, + } + if match: + updates.update( + { "matched_regex_hash": match.regex_hash, - }, + "matched_regex": match.regex, + "matched_value": match.value, + } + ) + if extra_updates: + updates.update(extra_updates) + + cell = self._update_cell(cell, now, **updates) + self.storage.insert_transition( + { + "cell_key": cell["cell_key"], + "profile_ip": cell["profile_ip"], + "regex_type": cell["regex_type"], + "antigen_value": cell["antigen_value"], + "evidence_id": evidence.id, + "observation_id": observation_id, + "from_state": from_state, + "to_state": to_state, + "reason": reason, + "matched_regex_hash": cell.get("matched_regex_hash"), + "matched_regex": cell.get("matched_regex"), + "matched_value": cell.get("matched_value"), + "scores": scores or {}, + "created_at": now, + } + ) + self._log_event( + action=reason, + state=to_state, + evidence=evidence, + cell=cell, + match=match, + metrics=scores, + verbosity=LOG_VERBOSITY_SUMMARY, + ) + return cell + + def _update_cell(self, cell: dict, now: float, **updates) -> dict: + cell.update(updates) + cell["updated_at"] = now + self.storage.upsert_cell(cell) + return cell + + @staticmethod + def _merge_cell_context_values(cell: dict, **updates) -> dict: + merged = dict(cell.get("context") or {}) + for key, value in updates.items(): + if value is CONTEXT_REMOVE: + merged.pop(key, None) + continue + merged[key] = value + return merged + + def _update_cell_context(self, cell: dict, now: float, **updates) -> dict: + return self._update_cell( + cell, + now, + context=self._merge_cell_context_values(cell, **updates), + ) + + def _remember_match_context( + self, + cell: dict, + now: float, + observation_id: int, + evidence_id: str, + match: RegexMatch, + ) -> dict: + return self._update_cell_context( + cell, + now, + recognition_observation_id=observation_id, + recognition_evidence_id=evidence_id, + matched_regex_created_at=match.created_at, + matched_regex_specificity=match.specificity, + ) + + def _clear_waiting_context(self, cell: dict, now: float) -> dict: + return self._update_cell_context( + cell, + now, + waiting_for=CONTEXT_REMOVE, + waiting_label=CONTEXT_REMOVE, + waiting_since=CONTEXT_REMOVE, + wait_deadline=CONTEXT_REMOVE, + wait_trigger_signal=CONTEXT_REMOVE, + wait_trigger_evidence_id=CONTEXT_REMOVE, + wait_trigger_observation_id=CONTEXT_REMOVE, + ) + + def _set_waiting_context( + self, + cell: dict, + now: float, + waiting_for: str, + evidence, + observation_id: int, + ) -> dict: + context = cell.get("context") or {} + waiting_since = context.get("waiting_since") + if context.get("waiting_for") != waiting_for or waiting_since is None: + waiting_since = ( + cell.get("last_transition_at") + or cell.get("created_at") + or now + ) + try: + waiting_since = float(waiting_since) + except (TypeError, ValueError): + waiting_since = float(now) + return self._update_cell_context( + cell, + now, + waiting_for=waiting_for, + waiting_label=WAITING_LABELS.get(waiting_for, waiting_for), + waiting_since=waiting_since, + wait_deadline=waiting_since + self.state_wait_timeout_seconds, + wait_trigger_signal=str(evidence.evidence_signal), + wait_trigger_evidence_id=evidence.id, + wait_trigger_observation_id=observation_id, + ) + + def _get_reference_observation_id( + self, cell: dict, fallback_observation_id: int + ) -> int: + context = cell.get("context") or {} + candidate_id = ( + context.get("recognition_observation_id") + or cell.get("last_observation_id") + or fallback_observation_id + ) + try: + return int(candidate_id) + except (TypeError, ValueError): + return int(fallback_observation_id) + + def _build_match_from_cell(self, cell: dict) -> RegexMatch | None: + regex_hash = str(cell.get("matched_regex_hash") or "").strip() + regex = str(cell.get("matched_regex") or "").strip() + regex_type = str(cell.get("regex_type") or "").strip() + value = str( + cell.get("matched_value") or cell.get("antigen_value") or "" + ).strip() + if not (regex_hash and regex and regex_type and value): + return None + + context = cell.get("context") or {} + created_at = context.get("matched_regex_created_at") or 0.0 + try: + created_at = float(created_at) + except (TypeError, ValueError): + created_at = 0.0 + + specificity = context.get("matched_regex_specificity") + try: + specificity = float(specificity) + except (TypeError, ValueError): + specificity = measure_regex_specificity(regex) + + return RegexMatch( + regex_type=regex_type, + value=value, + regex_hash=regex_hash, + regex=regex, + created_at=created_at, + specificity=specificity, + ) + + def _reevaluate_waiting_cells( + self, + evidence, + observation_id: int, + responsible_ip: str, + now: float, + ) -> int: + waiting_cells = self.storage.get_cells_for_profile_states( + responsible_ip, + [STATE_ANTIGEN_RECOGNIZED, STATE_ACTIVATED], + ) + reevaluated = 0 + for cell in waiting_cells: + match = self._build_match_from_cell(cell) + if not match: + self._log_event( + action="waiting_cell_missing_match", + state=cell["state"], + evidence=evidence, + cell=cell, + details=( + "cannot reevaluate waiting cell because the stored " + "regex match metadata is incomplete" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + continue + + candidate = AntigenCandidate( + regex_type=cell["regex_type"], + value=cell["antigen_value"], + ) + reference_observation_id = self._get_reference_observation_id( + cell, + observation_id, + ) + self._advance_cell_with_match( + cell=cell, + evidence=evidence, + observation_id=observation_id, + candidate=candidate, + match=match, + now=now, + responsible_ip=responsible_ip, + reference_observation_id=reference_observation_id, + ) + reevaluated += 1 + return reevaluated + + def _advance_cell_with_match( + self, + cell: dict, + evidence, + observation_id: int, + candidate: AntigenCandidate, + match: RegexMatch, + now: float, + responsible_ip: str, + reference_observation_id: int, + ) -> RegexMatch: + if ( + cell.get("last_observation_id") != observation_id + or cell.get("last_evidence_id") != evidence.id + ): + cell = self._update_cell( + cell, + now, + last_observation_id=observation_id, + last_evidence_id=evidence.id, + ) + + if cell["state"] == STATE_MEMORY: + cell = self._update_cell_context( + cell, + now, + reason="memory_retained", + observation_id=observation_id, + matched_regex_hash=match.regex_hash, ) self._log_event( action="memory_retained", @@ -435,7 +797,7 @@ def _process_candidate( co_stimulation = self._compute_co_stimulation( responsible_ip, - observation_id, + reference_observation_id, candidate, match, now, @@ -444,7 +806,11 @@ def _process_candidate( cell, now, last_co_stimulation=co_stimulation["value"], - context={"co_stimulation": co_stimulation}, + ) + cell = self._update_cell_context( + cell, + now, + co_stimulation=co_stimulation, ) if cell["state"] < STATE_ACTIVATED: @@ -473,6 +839,7 @@ def _process_candidate( match=match, scores=co_stimulation, ) + cell = self._clear_waiting_context(cell, now) elif ( cell["state"] == STATE_ANTIGEN_RECOGNIZED and self._state_wait_expired(cell, now) @@ -513,8 +880,16 @@ def _process_candidate( "anergic_until": now + self.anergy_ttl_seconds, }, ) + cell = self._clear_waiting_context(cell, now) return match else: + cell = self._set_waiting_context( + cell, + now, + WAITING_CO_STIMULATION, + evidence, + observation_id, + ) self._maybe_trace_co_stimulation( action="waiting_for_co_stimulation", evidence=evidence, @@ -540,8 +915,8 @@ def _process_candidate( match=match, details=( "score below threshold; keeping the cell in " - "antigen-recognized state until more corroborating " - "PAMPs arrive" + "antigen-recognized state and reevaluating on future " + "PAMP or DAMP evidence" ), metrics={ "score": co_stimulation["value"], @@ -567,7 +942,7 @@ def _process_candidate( context = self._compute_context_signals( responsible_ip, - observation_id, + reference_observation_id, candidate, match, now, @@ -577,7 +952,12 @@ def _process_candidate( now, last_effector_score=context["effector_score"], last_memory_score=context["memory_score"], - context={"co_stimulation": co_stimulation, "context": context}, + ) + cell = self._update_cell_context( + cell, + now, + co_stimulation=co_stimulation, + context=context, ) if context["effector"]: @@ -605,6 +985,7 @@ def _process_candidate( match=match, scores=context, ) + cell = self._clear_waiting_context(cell, now) self._apply_effector( cell, evidence, @@ -640,6 +1021,7 @@ def _process_candidate( match=match, scores=context, ) + cell = self._clear_waiting_context(cell, now) self._store_memory(cell, match, context, now) self._log_event( action="memory_stored", @@ -674,7 +1056,7 @@ def _process_candidate( from_state=cell["state"], to_state=STATE_MATURE, ) - self._transition_cell( + cell = self._transition_cell( cell=cell, to_state=STATE_MATURE, reason="context_timeout", @@ -688,8 +1070,16 @@ def _process_candidate( "wait_limit": self.state_wait_timeout_seconds, }, ) + cell = self._clear_waiting_context(cell, now) return match + cell = self._set_waiting_context( + cell, + now, + WAITING_CONTEXT, + evidence, + observation_id, + ) self._maybe_trace_context( action="waiting_for_context", evidence=evidence, @@ -715,7 +1105,8 @@ def _process_candidate( match=match, details=( "context is not strong enough yet for effector or memory; " - "keeping the current state and reevaluating on future PAMPs" + "keeping the current state and reevaluating on future PAMP " + "or DAMP evidence" ), metrics={ "effector_score": context["effector_score"], @@ -737,104 +1128,6 @@ def _process_candidate( ) return match - def _get_or_create_cell( - self, profile_ip: str, regex_type: str, antigen_value: str, now: float - ) -> dict: - cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) - cell = self.storage.get_cell(cell_key) - if cell: - return cell - - return { - "cell_key": cell_key, - "profile_ip": profile_ip, - "regex_type": regex_type, - "antigen_value": antigen_value, - "state": STATE_MATURE, - "state_name": STATE_INFO[STATE_MATURE]["label"], - "matched_regex_hash": None, - "matched_regex": None, - "matched_value": None, - "anergic_until": None, - "effector_cooldown_until": None, - "last_observation_id": None, - "last_evidence_id": None, - "last_transition_at": None, - "last_co_stimulation": None, - "last_effector_score": None, - "last_memory_score": None, - "context": {}, - "created_at": now, - "updated_at": now, - } - - def _transition_cell( - self, - cell: dict, - to_state: int, - reason: str, - evidence, - observation_id: int, - now: float, - match: RegexMatch | None = None, - scores: dict | None = None, - extra_updates: dict | None = None, - ) -> dict: - from_state = cell["state"] - updates = { - "state": to_state, - "state_name": STATE_INFO[to_state]["label"], - "last_observation_id": observation_id, - "last_evidence_id": evidence.id, - "last_transition_at": now, - } - if match: - updates.update( - { - "matched_regex_hash": match.regex_hash, - "matched_regex": match.regex, - "matched_value": match.value, - } - ) - if extra_updates: - updates.update(extra_updates) - - cell = self._update_cell(cell, now, **updates) - self.storage.insert_transition( - { - "cell_key": cell["cell_key"], - "profile_ip": cell["profile_ip"], - "regex_type": cell["regex_type"], - "antigen_value": cell["antigen_value"], - "evidence_id": evidence.id, - "observation_id": observation_id, - "from_state": from_state, - "to_state": to_state, - "reason": reason, - "matched_regex_hash": cell.get("matched_regex_hash"), - "matched_regex": cell.get("matched_regex"), - "matched_value": cell.get("matched_value"), - "scores": scores or {}, - "created_at": now, - } - ) - self._log_event( - action=reason, - state=to_state, - evidence=evidence, - cell=cell, - match=match, - metrics=scores, - verbosity=LOG_VERBOSITY_SUMMARY, - ) - return cell - - def _update_cell(self, cell: dict, now: float, **updates) -> dict: - cell.update(updates) - cell["updated_at"] = now - self.storage.upsert_cell(cell) - return cell - def _compute_co_stimulation( self, profile_ip: str, @@ -877,6 +1170,8 @@ def _compute_co_stimulation( return { "value": value, "confidence": confidence, + "confidence_observation_id": current_observation.get("id"), + "confidence_evidence_id": current_observation.get("evidence_id"), "related_pamp_count": related_pamp_count, "related_pamp_score": related_pamp_score, "profile_danger_score": profile_danger_score, @@ -1059,7 +1354,13 @@ def _maybe_trace_co_stimulation( self.co_stimulation_weights["confidence"] * co_stimulation["confidence"] ), - "evidence_id": evidence.id, + "evidence_id": co_stimulation.get( + "confidence_evidence_id" + ) + or evidence.id, + "observation_id": co_stimulation.get( + "confidence_observation_id" + ), }, "related_pamps": { "count": co_stimulation["related_pamp_count"], @@ -1416,7 +1717,12 @@ def _apply_effector( cell, now, effector_cooldown_until=next_cooldown, - context={"context": context, "effector_payload": blocking_data}, + ) + self._update_cell_context( + cell, + now, + context=context, + effector_payload=blocking_data, ) if self._blocking_modules_available(): @@ -1850,8 +2156,21 @@ def _resolve_trace_file_path(self, raw_path: str) -> str: safe_parts = ["t_cell_trace.jsonl"] return os.path.join(self.output_dir, *safe_parts) - def _colorize_state(self, state: int) -> str: + @staticmethod + def _get_waiting_label(cell: dict | None) -> str: + context = (cell or {}).get("context") or {} + waiting_for = context.get("waiting_for") + return WAITING_LABELS.get(waiting_for, "") + + def _format_state_label(self, state: int, cell: dict | None = None) -> str: label = STATE_INFO[state]["label"] + waiting_label = self._get_waiting_label(cell) + if waiting_label: + return f"{label} ({waiting_label})" + return label + + def _colorize_state(self, state: int, cell: dict | None = None) -> str: + label = self._format_state_label(state, cell) if not self.log_colors: return label return f"{STATE_INFO[state]['color']}{label}{COLOR_RESET}" @@ -1874,7 +2193,7 @@ def _log_event( f"action={action}", ] if state is not None: - parts.append(f"state={self._colorize_state(state)}") + parts.append(f"state={self._colorize_state(state, cell=cell)}") if evidence: parts.append(f"evidence={evidence.evidence_type.name}") parts.append(f"eid={evidence.id}") @@ -1888,6 +2207,9 @@ def _log_event( parts.append(f"target={target_ip}") if cell: parts.append(f"cell={cell['cell_key']}") + waiting_label = self._get_waiting_label(cell) + if waiting_label: + parts.append(f"waiting={waiting_label}") if match: parts.append(f"regex={match.regex_hash}") parts.append(f"value={match.value}") From 6d903da1d76434bd4e6fd7e3605c29d682b08f7a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:57 +0000 Subject: [PATCH 0665/1100] feat: add method to retrieve cells for specific profile states in TCellSQLiteDB --- .../core/database/sqlite_db/t_cell_db.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/slips_files/core/database/sqlite_db/t_cell_db.py b/slips_files/core/database/sqlite_db/t_cell_db.py index bfd40367e1..8441bf8683 100644 --- a/slips_files/core/database/sqlite_db/t_cell_db.py +++ b/slips_files/core/database/sqlite_db/t_cell_db.py @@ -285,6 +285,27 @@ def get_all_cells(self) -> list[dict]: rows = self.select("cells", order_by="updated_at DESC") or [] return [self._row_to_cell(row) for row in rows] + def get_cells_for_profile_states( + self, profile_ip: str, states: list[int] | tuple[int, ...] + ) -> list[dict]: + normalized_states = [ + int(state) for state in (states or []) if state is not None + ] + if not normalized_states: + return [] + + placeholders = ", ".join("?" for _ in normalized_states) + rows = self.select( + "cells", + condition=( + f"profile_ip = ? AND state IN ({placeholders})" + ), + params=(profile_ip, *normalized_states), + order_by="updated_at DESC, created_at DESC", + ) + rows = rows or [] + return [self._row_to_cell(row) for row in rows] + def upsert_cell(self, record: dict): self.execute( "INSERT OR REPLACE INTO cells (" @@ -568,6 +589,11 @@ def get_cell(self, cell_key: str) -> dict | None: def get_all_cells(self) -> list[dict]: return self.db.get_all_cells() + def get_cells_for_profile_states( + self, profile_ip: str, states: list[int] | tuple[int, ...] + ) -> list[dict]: + return self.db.get_cells_for_profile_states(profile_ip, states) + def upsert_cell(self, record: dict): self.db.upsert_cell(record) From c0d273309534a0230440a06dddebba6234ae4720 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:13 +0000 Subject: [PATCH 0666/1100] feat: add upsert functionality for activated cell state and update report assertions --- .../modules/t_cell/test_analyze_t_cell.py | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index 454a5fb29a..ba4812dd59 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -151,6 +151,34 @@ def test_build_report_payload_and_html(tmp_path): "updated_at": 2000.3, } ) + storage.upsert_cell( + { + "cell_key": "192.168.1.121|tls_sni|arpanet-network.com", + "profile_ip": "192.168.1.121", + "regex_type": "tls_sni", + "antigen_value": "arpanet-network.com", + "state": 3, + "state_name": "3 - activated", + "matched_regex_hash": "regex-hash-2", + "matched_regex": r"arpanet-network\.com$", + "matched_value": "arpanet-network.com", + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": pamp_observation_id, + "last_evidence_id": "pamp-1", + "last_transition_at": 2000.4, + "last_co_stimulation": 1.0, + "last_effector_score": 0.70, + "last_memory_score": 0.40, + "context": { + "waiting_for": "context", + "waiting_since": 2000.4, + "wait_deadline": 2060.4, + }, + "created_at": 2000.4, + "updated_at": 2000.4, + } + ) storage.insert_transition( { "cell_key": cell_key, @@ -300,10 +328,15 @@ def test_build_report_payload_and_html(tmp_path): assert payload["totals"]["signals"] == {"DAMP": 1, "PAMP": 1} assert payload["totals"]["transitions"] == 3 assert payload["totals"]["memories"] == 1 - assert payload["cell_states"] == {"5 - memory": 1} + assert payload["cell_states"]["5 - memory"] == 1 + assert payload["cell_states"]["3 - activated"] == 1 assert payload["sources"]["trace_enabled"] is True assert payload["trace"]["total_rows"] == 2 assert payload["recent_observations"][0]["category"] == "PAMP with regex match" + assert any( + row["waiting_label"] == "waiting for context" + for row in payload["recent_cells"] + ) assert any( row["category"] == "DAMP with extracted antigens" for row in payload["recent_observations"] @@ -319,10 +352,11 @@ def test_build_report_payload_and_html(tmp_path): assert "Decision Trace" in html assert "T Cell State Machine" in html assert "regex match" in html - assert "current cells: 1" in html + assert "current cells:" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html assert "data-sortable-table='recent-transitions'" in html + assert "data-sortable-table='recent-cells'" in html assert "data-default-sort-column='4'" in html assert "Default order groups rows by T cell" in html assert "Click a column header to sort." in html @@ -332,5 +366,7 @@ def test_build_report_payload_and_html(tmp_path): assert "bad.example.com" in html assert "DAMP with extracted antigens" in html assert "PAMP with regex match" in html + assert "waiting for context" in html + assert "3 - activated (waiting for context)" not in html storage.close() From c9a829b46685cfe3a970efe8c15b63dd17c15a21 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:22 +0000 Subject: [PATCH 0667/1100] feat: enhance DAMP evidence handling and add tests for waiting cell re-evaluation --- tests/unit/modules/t_cell/test_t_cell.py | 130 ++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/tests/unit/modules/t_cell/test_t_cell.py b/tests/unit/modules/t_cell/test_t_cell.py index 9f39d534c7..6e14d84668 100644 --- a/tests/unit/modules/t_cell/test_t_cell.py +++ b/tests/unit/modules/t_cell/test_t_cell.py @@ -228,7 +228,7 @@ def test_extract_antigen_candidates_from_entities_and_altflows(tmp_path): assert ("certificate_cn", "cn.bad.example.com") in extracted -def test_t_cell_ignores_damp_evidence(tmp_path): +def test_t_cell_stores_damp_evidence_and_checks_waiting_cells(tmp_path): t_cell, storage = _prepare_t_cell(tmp_path) evidence = _build_evidence("damp-1", signal=EvidenceSignal.DAMP) @@ -242,7 +242,8 @@ def test_t_cell_ignores_damp_evidence(tmp_path): t_cell.db.publish.assert_not_called() with open(t_cell.log_file_path, encoding="utf-8") as log_file: log_contents = log_file.read() - assert "ignored_non_pamp" in log_contents + assert "damp_reverification" in log_contents + assert "reevaluated_cells=0" in log_contents assert "signal=DAMP" in log_contents @@ -797,7 +798,7 @@ def test_t_cell_summary_log_hides_waiting_for_co_stimulation(tmp_path): def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): - t_cell, _ = _prepare_t_cell(tmp_path, log_verbosity=2) + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) evidence = _build_evidence("pending-2", uids=["dns-1"]) t_cell.db.get_altflow_from_uid.return_value = { "type_": "dns", @@ -813,12 +814,135 @@ def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): with open(t_cell.log_file_path, encoding="utf-8") as log_file: log_contents = log_file.read() + cell = storage.get_all_cells()[0] + assert cell["context"]["waiting_for"] == "co_stimulation" assert "waiting_for_co_stimulation" in log_contents + assert "waiting=waiting for co-stimulation" in log_contents assert "score=" in log_contents assert "threshold=" in log_contents assert "related_pamps=" in log_contents +def test_t_cell_damp_reverifies_waiting_co_stimulation_cells(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + fixed_now = 14_500.0 + profile_ip = "10.0.0.80" + evidence_pamp = _build_evidence( + "damp-reverify-costim-pamp", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + evidence_damp = _build_evidence( + "damp-reverify-costim-damp", + signal=EvidenceSignal.DAMP, + profile_ip=profile_ip, + threat_level=ThreatLevel.CRITICAL, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-reverify-costim-regex" + ) + _insert_observation( + storage=storage, + evidence_id="seed-damp-1", + profile_ip=profile_ip, + antigens=[], + observed_at=fixed_now - 20, + confidence=1.0, + threat_level_value=1.0, + threat_level="critical", + evidence_signal="DAMP", + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_pamp)) + + first_cell = storage.get_all_cells()[0] + assert first_cell["state"] == STATE_ANTIGEN_RECOGNIZED + assert first_cell["context"]["waiting_for"] == "co_stimulation" + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_damp)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_ACTIVATED + assert cell["context"]["waiting_for"] == "context" + assert any( + transition["reason"] == "co_stimulation_threshold_met" + and transition["evidence_id"] == evidence_damp.id + for transition in transitions + ) + + +def test_t_cell_damp_reverifies_waiting_context_cells(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + fixed_now = 14_800.0 + profile_ip = "10.0.0.81" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence_pamp = _build_evidence( + "damp-reverify-context-pamp", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + evidence_damp = _build_evidence( + "damp-reverify-context-damp", + signal=EvidenceSignal.DAMP, + profile_ip=profile_ip, + threat_level=ThreatLevel.CRITICAL, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-reverify-context-regex" + ) + t_cell.db.get_pid_of.side_effect = ( + lambda name: 123 if name == "Blocking" else None + ) + _seed_recent_related_observations( + storage, + profile_ip, + antigen, + fixed_now, + count=5, + confidence=1.0, + threat_level_value=0.1, + age_seconds=120, + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_pamp)) + + first_cell = storage.get_all_cells()[0] + assert first_cell["state"] == STATE_ACTIVATED + assert first_cell["context"]["waiting_for"] == "context" + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_damp)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_EFFECTOR + assert "waiting_for" not in cell["context"] + assert any( + transition["reason"] == "context_effector" + and transition["evidence_id"] == evidence_damp.id + for transition in transitions + ) + assert t_cell.db.publish.call_count == 1 + + def test_t_cell_log_file_contains_color_codes(tmp_path): t_cell, _ = _prepare_t_cell(tmp_path) evidence = _build_evidence("log-1") From 8775a8ab56cf0c97032125b62ce1c8e3b3dde281 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:30 +0000 Subject: [PATCH 0668/1100] feat: add T Cell report overview image to documentation --- docs/images/t_cell/t_cell_report_overview.png | Bin 0 -> 617207 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/t_cell/t_cell_report_overview.png diff --git a/docs/images/t_cell/t_cell_report_overview.png b/docs/images/t_cell/t_cell_report_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..82fcbf4649af910b57f2c9b1f1feaa56d0cae31b GIT binary patch literal 617207 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

    S_UTAO{Kq(wuHbDYV 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

    """ From b98d8e050dd9e265d91391e1a423ecad4170652e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:36 +0200 Subject: [PATCH 0672/1100] feat: add script to analyze alert creation delay from Slips alerts exports --- scripts/analyze_alert_creation_delay.py | 632 ++++++++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100755 scripts/analyze_alert_creation_delay.py diff --git a/scripts/analyze_alert_creation_delay.py b/scripts/analyze_alert_creation_delay.py new file mode 100755 index 0000000000..40f0038db5 --- /dev/null +++ b/scripts/analyze_alert_creation_delay.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Analyze alert creation delay from Slips alerts exports. + +This script measures the delay between each alert's CreateTime and StartTime, +then summarizes the distribution and how it evolves over time. It supports the +newline-delimited JSON format used by alerts.json as well as plain JSON arrays. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import sys +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path + + +DEFAULT_RESOLUTIONS = ("day", "hour", "minute") +VALID_RESOLUTIONS = set(DEFAULT_RESOLUTIONS) +DELAY_BANDS = ( + ("negative", None, 0.0), + ("0s-1s", 0.0, 1.0), + ("1s-10s", 1.0, 10.0), + ("10s-60s", 10.0, 60.0), + ("1m-5m", 60.0, 300.0), + ("5m-1h", 300.0, 3600.0), + ("1h-1d", 3600.0, 86400.0), + (">=1d", 86400.0, None), +) + + +@dataclass(frozen=True) +class AlertDelayRecord: + record_number: int + alert_id: str + severity: str + create_time: str + start_time: str + delay_seconds: float + description: str + + +@dataclass(frozen=True) +class SummaryStats: + count: int + min_seconds: float + mean_seconds: float + p50_seconds: float + p90_seconds: float + p95_seconds: float + p99_seconds: float + max_seconds: float + + +@dataclass(frozen=True) +class BucketSummary: + bucket_start: str + count: int + min_seconds: float + mean_seconds: float + p50_seconds: float + p95_seconds: float + p99_seconds: float + max_seconds: float + + +def parse_args() -> argparse.Namespace: + class HelpFormatter( + argparse.ArgumentDefaultsHelpFormatter, + argparse.RawDescriptionHelpFormatter, + ): + pass + + parser = argparse.ArgumentParser( + description=( + "Analyze alert creation delay in Slips alerts exports.\n\n" + "The script reads alerts.json, computes the per-alert delay as\n" + "CreateTime - StartTime, then summarizes the overall distribution\n" + "and how that delay evolves over time by day, hour, and minute." + ), + epilog=( + "Input format:\n" + " alerts.json can be newline-delimited JSON (one alert per line)\n" + " or a regular JSON array of alert objects.\n\n" + "Outputs:\n" + " The terminal output shows overall statistics, delay bands,\n" + " the alerts with the largest delays, and trend tables.\n" + " If --output-dir is given, the script also writes CSV files for\n" + " each selected time resolution plus a summary.json file.\n\n" + "Example:\n" + " python3 scripts/analyze_alert_creation_delay.py \\\n" + " output/test-tcell-8/alerts.json \\\n" + " --output-dir output/test-tcell-8/alert_creation_delay_report" + ), + formatter_class=HelpFormatter, + ) + parser.add_argument( + "alerts_path", + help="Path to alerts.json (JSONL or JSON array).", + ) + parser.add_argument( + "--bucket-time", + choices=("create", "start"), + default="create", + help=( + "Which timestamp to use for trend buckets. Default: create " + "(group by CreateTime)." + ), + ) + parser.add_argument( + "--resolution", + action="append", + choices=sorted(VALID_RESOLUTIONS), + help=( + "Trend resolution to emit. Repeat to select a subset. " + "Default: day, hour, minute." + ), + ) + parser.add_argument( + "--output-dir", + default="", + help=( + "Optional directory where CSV trend files, top-delays CSV, and " + "summary.json will be written." + ), + ) + parser.add_argument( + "--print-limit", + type=int, + default=120, + help=( + "Print all buckets when a resolution has at most this many buckets. " + "Default: 120." + ), + ) + parser.add_argument( + "--top-buckets", + type=int, + default=10, + help=( + "When a resolution has many buckets, print this many worst buckets " + "and this many most recent buckets. Default: 10." + ), + ) + parser.add_argument( + "--top-alerts", + type=int, + default=10, + help="Show this many alerts with the largest delays. Default: 10.", + ) + parser.add_argument( + "--description-width", + type=int, + default=110, + help="Maximum description width in the top-alerts section. Default: 110.", + ) + return parser.parse_args() + + +def detect_input_format(path: Path) -> str: + with path.open(encoding="utf-8") as handle: + while True: + char = handle.read(1) + if not char: + raise ValueError(f"{path} is empty") + if char.isspace(): + continue + return "json-array" if char == "[" else "jsonl" + + +def iter_alert_records(path: Path): + input_format = detect_input_format(path) + if input_format == "json-array": + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, list): + raise ValueError(f"{path} is a JSON array file but did not contain a list") + for index, alert in enumerate(payload, start=1): + if not isinstance(alert, dict): + raise ValueError(f"Record {index} is not a JSON object") + yield input_format, index, alert + return + + with path.open(encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + stripped = line.strip() + if not stripped: + continue + try: + alert = json.loads(stripped) + except json.JSONDecodeError as exc: + raise ValueError( + f"Invalid JSON on line {line_number}: {exc.msg}" + ) from exc + if not isinstance(alert, dict): + raise ValueError(f"Line {line_number} is not a JSON object") + yield input_format, line_number, alert + + +def parse_timestamp(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +def truncate_datetime(value: datetime, resolution: str) -> datetime: + if resolution == "day": + return value.replace(hour=0, minute=0, second=0, microsecond=0) + if resolution == "hour": + return value.replace(minute=0, second=0, microsecond=0) + if resolution == "minute": + return value.replace(second=0, microsecond=0) + raise ValueError(f"Unsupported resolution: {resolution}") + + +def percentile(sorted_values: list[float], fraction: float) -> float: + if not sorted_values: + raise ValueError("percentile() requires at least one value") + if len(sorted_values) == 1: + return sorted_values[0] + position = (len(sorted_values) - 1) * fraction + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return sorted_values[lower] + lower_value = sorted_values[lower] + upper_value = sorted_values[upper] + return lower_value + (upper_value - lower_value) * (position - lower) + + +def build_summary(values: list[float]) -> SummaryStats: + if not values: + raise ValueError("No values available to summarize") + ordered = sorted(values) + return SummaryStats( + count=len(ordered), + min_seconds=ordered[0], + mean_seconds=sum(ordered) / len(ordered), + p50_seconds=percentile(ordered, 0.50), + p90_seconds=percentile(ordered, 0.90), + p95_seconds=percentile(ordered, 0.95), + p99_seconds=percentile(ordered, 0.99), + max_seconds=ordered[-1], + ) + + +def build_bucket_summaries( + bucket_values: dict[datetime, list[float]] +) -> list[BucketSummary]: + summaries: list[BucketSummary] = [] + for bucket_start, values in sorted(bucket_values.items()): + ordered = sorted(values) + summaries.append( + BucketSummary( + bucket_start=bucket_start.isoformat(), + count=len(ordered), + min_seconds=ordered[0], + mean_seconds=sum(ordered) / len(ordered), + p50_seconds=percentile(ordered, 0.50), + p95_seconds=percentile(ordered, 0.95), + p99_seconds=percentile(ordered, 0.99), + max_seconds=ordered[-1], + ) + ) + return summaries + + +def delay_band_label(delay_seconds: float) -> str: + for label, lower, upper in DELAY_BANDS: + if lower is None and delay_seconds < upper: + return label + if upper is None and delay_seconds >= lower: + return label + if lower is not None and upper is not None and lower <= delay_seconds < upper: + return label + return "unclassified" + + +def ellipsize(text: str, width: int) -> str: + if width <= 3 or len(text) <= width: + return text + return text[: width - 3] + "..." + + +def print_summary_stats(summary: SummaryStats): + print("Overall delay statistics (CreateTime - StartTime, in seconds)") + print(f" alerts: {summary.count:,}") + print(f" min_s: {summary.min_seconds:.6f}") + print(f" mean_s: {summary.mean_seconds:.6f}") + print(f" p50_s: {summary.p50_seconds:.6f}") + print(f" p90_s: {summary.p90_seconds:.6f}") + print(f" p95_s: {summary.p95_seconds:.6f}") + print(f" p99_s: {summary.p99_seconds:.6f}") + print(f" max_s: {summary.max_seconds:.6f}") + + +def print_delay_bands(band_counts: dict[str, int], total: int): + print("\nDelay bands") + for label, _, _ in DELAY_BANDS: + count = band_counts.get(label, 0) + percentage = (count / total * 100) if total else 0.0 + print(f" {label:>8}: {count:>9,} ({percentage:6.2f}%)") + + +def print_top_alerts(top_alerts: list[AlertDelayRecord], description_width: int): + if not top_alerts: + return + print("\nLargest per-alert delays") + for rank, item in enumerate(top_alerts, start=1): + description = ellipsize(item.description.replace("\n", " "), description_width) + print( + f" {rank:>2}. delay_s={item.delay_seconds:>12.6f} " + f"record={item.record_number:<8} severity={item.severity or '-':<6} " + f"id={item.alert_id or '-'}" + ) + print( + f" start={item.start_time} create={item.create_time} " + f"description={description}" + ) + + +def print_bucket_table(rows: list[BucketSummary]): + if not rows: + print(" no buckets") + return + header = ( + f"{'bucket_start':<25} {'count':>8} {'min_s':>12} {'mean_s':>12} " + f"{'p50_s':>12} {'p95_s':>12} {'p99_s':>12} {'max_s':>12}" + ) + print(header) + print("-" * len(header)) + for row in rows: + print( + f"{row.bucket_start:<25} {row.count:>8,} " + f"{row.min_seconds:>12.3f} {row.mean_seconds:>12.3f} " + f"{row.p50_seconds:>12.3f} {row.p95_seconds:>12.3f} " + f"{row.p99_seconds:>12.3f} {row.max_seconds:>12.3f}" + ) + + +def print_resolution_summary( + resolution: str, + rows: list[BucketSummary], + print_limit: int, + top_buckets: int, + csv_path: Path | None, +): + print(f"\nBy {resolution}") + if not rows: + print(" no data") + return + + first_row = rows[0] + last_row = rows[-1] + print( + f" buckets: {len(rows):,}; first={first_row.bucket_start}; " + f"last={last_row.bucket_start}" + ) + print( + f" first mean/p50/p95: {first_row.mean_seconds:.3f} / " + f"{first_row.p50_seconds:.3f} / {first_row.p95_seconds:.3f} seconds" + ) + print( + f" last mean/p50/p95: {last_row.mean_seconds:.3f} / " + f"{last_row.p50_seconds:.3f} / {last_row.p95_seconds:.3f} seconds" + ) + if csv_path is not None: + print(f" csv: {csv_path}") + + if len(rows) <= print_limit: + print_bucket_table(rows) + return + + worst_rows = sorted( + rows, + key=lambda row: (row.p95_seconds, row.max_seconds, row.mean_seconds), + reverse=True, + )[:top_buckets] + recent_rows = rows[-top_buckets:] + + print(f" {len(rows):,} buckets exceed --print-limit={print_limit}.") + print(f" Worst {len(worst_rows)} buckets by p95_s") + print_bucket_table(sorted(worst_rows, key=lambda row: row.bucket_start)) + print(f"\n Most recent {len(recent_rows)} buckets") + print_bucket_table(recent_rows) + + +def write_bucket_csv(path: Path, rows: list[BucketSummary]): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "bucket_start", + "count", + "min_s", + "mean_s", + "p50_s", + "p95_s", + "p99_s", + "max_s", + ] + ) + for row in rows: + writer.writerow( + [ + row.bucket_start, + row.count, + f"{row.min_seconds:.6f}", + f"{row.mean_seconds:.6f}", + f"{row.p50_seconds:.6f}", + f"{row.p95_seconds:.6f}", + f"{row.p99_seconds:.6f}", + f"{row.max_seconds:.6f}", + ] + ) + + +def write_top_alerts_csv(path: Path, rows: list[AlertDelayRecord]): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "record_number", + "alert_id", + "severity", + "create_time", + "start_time", + "delay_s", + "description", + ] + ) + for row in rows: + writer.writerow( + [ + row.record_number, + row.alert_id, + row.severity, + row.create_time, + row.start_time, + f"{row.delay_seconds:.6f}", + row.description, + ] + ) + + +def ensure_output_dir(output_dir: str) -> Path | None: + if not output_dir: + return None + path = Path(output_dir).expanduser().resolve() + path.mkdir(parents=True, exist_ok=True) + return path + + +def main() -> int: + args = parse_args() + alerts_path = Path(args.alerts_path).expanduser().resolve() + if not alerts_path.exists(): + print(f"alerts file not found: {alerts_path}", file=sys.stderr) + return 1 + + resolutions = tuple(args.resolution or DEFAULT_RESOLUTIONS) + output_dir = ensure_output_dir(args.output_dir) + + overall_delays: list[float] = [] + bucket_values = { + resolution: defaultdict(list) for resolution in resolutions + } + band_counts: dict[str, int] = defaultdict(int) + top_delay_records: list[AlertDelayRecord] = [] + skipped_missing_timestamps = 0 + skipped_invalid_timestamps = 0 + negative_count = 0 + zero_count = 0 + trend_min: datetime | None = None + trend_max: datetime | None = None + input_format: str | None = None + + for current_format, record_number, alert in iter_alert_records(alerts_path): + input_format = current_format + create_time_raw = alert.get("CreateTime") + start_time_raw = alert.get("StartTime") + if not create_time_raw or not start_time_raw: + skipped_missing_timestamps += 1 + continue + + try: + create_time = parse_timestamp(create_time_raw) + start_time = parse_timestamp(start_time_raw) + except ValueError: + skipped_invalid_timestamps += 1 + continue + + delay_seconds = (create_time - start_time).total_seconds() + overall_delays.append(delay_seconds) + band_counts[delay_band_label(delay_seconds)] += 1 + if delay_seconds < 0: + negative_count += 1 + elif delay_seconds == 0: + zero_count += 1 + + top_delay_records.append( + AlertDelayRecord( + record_number=record_number, + alert_id=str(alert.get("ID") or ""), + severity=str(alert.get("Severity") or ""), + create_time=create_time_raw, + start_time=start_time_raw, + delay_seconds=delay_seconds, + description=str(alert.get("Description") or ""), + ) + ) + + trend_time = create_time if args.bucket_time == "create" else start_time + if trend_min is None or trend_time < trend_min: + trend_min = trend_time + if trend_max is None or trend_time > trend_max: + trend_max = trend_time + for resolution in resolutions: + bucket_values[resolution][ + truncate_datetime(trend_time, resolution) + ].append(delay_seconds) + + if not overall_delays: + print( + ( + "No alerts with valid CreateTime and StartTime were found in " + f"{alerts_path}" + ), + file=sys.stderr, + ) + return 1 + + overall_summary = build_summary(overall_delays) + top_delay_records = sorted( + top_delay_records, + key=lambda item: item.delay_seconds, + reverse=True, + )[: args.top_alerts] + bucket_summaries = { + resolution: build_bucket_summaries(bucket_values[resolution]) + for resolution in resolutions + } + + csv_paths: dict[str, str] = {} + if output_dir is not None: + for resolution in resolutions: + csv_path = output_dir / f"alert_creation_delay_by_{resolution}.csv" + write_bucket_csv(csv_path, bucket_summaries[resolution]) + csv_paths[resolution] = str(csv_path) + + top_alerts_csv = output_dir / "alert_creation_delay_top_alerts.csv" + write_top_alerts_csv(top_alerts_csv, top_delay_records) + csv_paths["top_alerts"] = str(top_alerts_csv) + + summary_json = output_dir / "summary.json" + summary_payload = { + "alerts_path": str(alerts_path), + "input_format": input_format, + "bucket_time": args.bucket_time, + "resolutions": list(resolutions), + "processed_alerts": overall_summary.count, + "skipped_missing_timestamps": skipped_missing_timestamps, + "skipped_invalid_timestamps": skipped_invalid_timestamps, + "negative_delays": negative_count, + "zero_delays": zero_count, + "trend_start": trend_min.isoformat() if trend_min else None, + "trend_end": trend_max.isoformat() if trend_max else None, + "overall_delay_seconds": asdict(overall_summary), + "delay_bands": [ + { + "label": label, + "count": band_counts.get(label, 0), + "percentage": ( + band_counts.get(label, 0) / overall_summary.count * 100 + ), + } + for label, _, _ in DELAY_BANDS + ], + "top_delays": [asdict(item) for item in top_delay_records], + "csv_outputs": csv_paths, + "bucket_counts": { + resolution: len(bucket_summaries[resolution]) + for resolution in resolutions + }, + } + with summary_json.open("w", encoding="utf-8") as handle: + json.dump(summary_payload, handle, indent=2) + handle.write("\n") + csv_paths["summary_json"] = str(summary_json) + + print(f"Input: {alerts_path}") + print(f"Input format: {input_format}") + print(f"Trend bucket timestamp: {args.bucket_time} time") + print( + f"Valid alerts: {overall_summary.count:,}; skipped missing timestamps: " + f"{skipped_missing_timestamps:,}; skipped invalid timestamps: " + f"{skipped_invalid_timestamps:,}" + ) + if trend_min is not None and trend_max is not None: + print(f"Trend range: {trend_min.isoformat()} -> {trend_max.isoformat()}") + print( + f"Negative delays: {negative_count:,}; zero delays: {zero_count:,}" + ) + print_summary_stats(overall_summary) + print_delay_bands(band_counts, overall_summary.count) + print_top_alerts(top_delay_records, args.description_width) + + for resolution in resolutions: + csv_path = Path(csv_paths[resolution]) if resolution in csv_paths else None + print_resolution_summary( + resolution=resolution, + rows=bucket_summaries[resolution], + print_limit=args.print_limit, + top_buckets=args.top_buckets, + csv_path=csv_path, + ) + + if output_dir is not None: + print(f"\nArtifacts written to: {output_dir}") + print(f"Summary JSON: {csv_paths['summary_json']}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 1fc7ccd1e45e42711f7ce841c457ec868cb5c06c Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:45 +0200 Subject: [PATCH 0673/1100] feat: add regex auditing and pruning script for benign threshold management --- scripts/regex_prune_benign_threshold.py | 649 ++++++++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100755 scripts/regex_prune_benign_threshold.py diff --git a/scripts/regex_prune_benign_threshold.py b/scripts/regex_prune_benign_threshold.py new file mode 100755 index 0000000000..fe14cec516 --- /dev/null +++ b/scripts/regex_prune_benign_threshold.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Audit and optionally prune accepted regexes that exceed the benign threshold. + +This is meant for persistent regex stores where the benign corpus may have +grown over time. A regex accepted earlier can later become too strong against +the current benign corpus even though it passed at generation time. +""" + +from __future__ import annotations + +import argparse +import json +import re +import signal +import shutil +import sqlite3 +import sys +import time +import warnings +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.core.database.sqlite_db.regex_generator_db import REGEX_TYPES +from modules.regex_generator.match_strength import ( + compute_match_strength, + measure_regex_specificity, +) + + +@dataclass +class RegexAuditResult: + id: int + regex_type: str + regex: str + regex_hash: str + created_at: float + strongest_benign_score: float + strongest_benign_value: str + + +class _NullTimeout: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, exc_tb): + return False + + +class _SignalTimeout: + def __init__(self, timeout_seconds: float): + self.timeout_seconds = timeout_seconds + self._previous_handler = None + + def __enter__(self): + self._previous_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, self._handle_timeout) + signal.setitimer(signal.ITIMER_REAL, self.timeout_seconds) + return None + + def __exit__(self, exc_type, exc, exc_tb): + signal.setitimer(signal.ITIMER_REAL, 0) + if self._previous_handler is not None: + signal.signal(signal.SIGALRM, self._previous_handler) + return False + + @staticmethod + def _handle_timeout(signum, frame): + raise TimeoutError("regex benign scan timed out") + + +def timeout_context(timeout_seconds: float): + if timeout_seconds <= 0: + return _NullTimeout() + return _SignalTimeout(timeout_seconds) + + +class AuditProgressTracker: + BAR_WIDTH = 24 + + def __init__(self, total_regexes: int, totals_by_type: dict[str, int]): + self.total_regexes = max(1, total_regexes) + self.totals_by_type = dict(totals_by_type) + self.done_regexes = 0 + self.done_by_type = {regex_type: 0 for regex_type in totals_by_type} + self.current_type = "-" + self.comparisons_done = 0 + self.flagged_done = 0 + self.timed_out_done = 0 + self.started_at = time.monotonic() + self.last_render_at = 0.0 + self.enabled = sys.stderr.isatty() + + def start(self): + if not self.enabled: + return + print( + ( + "Auditing accepted regexes against the current benign corpus " + f"({self.total_regexes} regexes)" + ), + file=sys.stderr, + flush=True, + ) + self._render(force=True) + + def advance( + self, + regex_type: str, + comparisons: int, + flagged_increment: int = 0, + timed_out_increment: int = 0, + ): + self.done_regexes += 1 + self.current_type = regex_type + self.comparisons_done += comparisons + self.flagged_done += flagged_increment + self.timed_out_done += timed_out_increment + self.done_by_type[regex_type] = self.done_by_type.get(regex_type, 0) + 1 + self._render() + + def finish(self): + if not self.enabled: + return + self._render(force=True, done=True) + print(file=sys.stderr, flush=True) + + def _render(self, force: bool = False, done: bool = False): + if not self.enabled: + return + + now = time.monotonic() + if not force and not done and now - self.last_render_at < 0.1: + return + self.last_render_at = now + + ratio = min(1.0, self.done_regexes / self.total_regexes) + filled = int(ratio * self.BAR_WIDTH) + bar = "[" + ("=" * filled) + ("." * (self.BAR_WIDTH - filled)) + "]" + elapsed = max(0.001, now - self.started_at) + if done or ratio >= 1.0: + eta = 0.0 + else: + eta = (elapsed / max(ratio, 1e-9)) - elapsed + + type_done = self.done_by_type.get(self.current_type, 0) + type_total = self.totals_by_type.get(self.current_type, 0) + status = ( + "\r" + f"{bar} {ratio * 100:6.2f}% " + f"| regex {self.done_regexes}/{self.total_regexes} " + f"| type {self.current_type} {type_done}/{type_total} " + f"| flagged {self.flagged_done} " + f"| timed out {self.timed_out_done} " + f"| cmp {self.comparisons_done:,} " + f"| ETA {self._format_duration(eta)}" + ) + print(status, end="", file=sys.stderr, flush=True) + + @staticmethod + def _format_duration(seconds: float) -> str: + total_seconds = max(0, int(seconds)) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Audit accepted regexes against the current benign corpus and " + "optionally delete those whose strongest benign match meets or " + "exceeds the configured threshold." + ) + ) + parser.add_argument( + "--run-output-dir", + default="", + help=( + "Slips run output directory containing regex_generator/*.sqlite, " + "or a direct regex store directory containing generated_regexes.sqlite " + "and benign_corpus.sqlite." + ), + ) + parser.add_argument( + "--regex-db", + default="", + help="Path to generated_regexes.sqlite. Overrides --run-output-dir.", + ) + parser.add_argument( + "--benign-db", + default="", + help="Path to benign_corpus.sqlite. Overrides --run-output-dir.", + ) + parser.add_argument( + "--threshold", + type=float, + default=None, + help=( + "Benign match-strength threshold. Defaults to " + "regex_generator.benign_match_strength_threshold from config, " + "or 75 if unavailable." + ), + ) + parser.add_argument( + "--regex-type", + action="append", + choices=sorted(REGEX_TYPES), + help="Limit the audit to one or more regex types.", + ) + parser.add_argument( + "--match-timeout-seconds", + type=float, + default=None, + help=( + "Maximum wall-clock seconds allowed for one accepted regex to scan " + "the benign corpus for its regex type. Timed-out regexes are " + "skipped and never deleted. Defaults to " + "regex_generator.regex_validation_timeout_seconds from config, " + "or 2.0 if unavailable. Set 0 to disable." + ), + ) + parser.add_argument( + "--limit", + type=int, + default=20, + help="Maximum number of example rows to print per regex type.", + ) + parser.add_argument( + "--output-json", + default="", + help="Optional JSON output path for the audit summary.", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete accepted regex rows that exceed the threshold.", + ) + parser.add_argument( + "--no-backup", + action="store_true", + help="Do not create a backup copy of generated_regexes.sqlite before deletion.", + ) + parser.add_argument( + "--vacuum", + action="store_true", + help="Run VACUUM on generated_regexes.sqlite after deletion.", + ) + return parser.parse_args() + + +def default_threshold() -> float: + try: + return float( + ConfigParser().regex_generator_benign_match_strength_threshold() + ) + except Exception: + return 75.0 + + +def default_match_timeout() -> float: + try: + return float(ConfigParser().regex_generator_regex_validation_timeout_seconds()) + except Exception: + return 2.0 + + +def resolve_paths(args: argparse.Namespace) -> tuple[Path, Path]: + if args.regex_db and args.benign_db: + return Path(args.regex_db).expanduser(), Path(args.benign_db).expanduser() + + if not args.run_output_dir: + raise SystemExit( + "Provide either --regex-db and --benign-db, or --run-output-dir." + ) + + base = Path(args.run_output_dir).expanduser() + direct_regex = base / "generated_regexes.sqlite" + direct_benign = base / "benign_corpus.sqlite" + nested_regex = base / "regex_generator" / "generated_regexes.sqlite" + nested_benign = base / "regex_generator" / "benign_corpus.sqlite" + + if direct_regex.exists() and direct_benign.exists(): + return direct_regex, direct_benign + if nested_regex.exists() and nested_benign.exists(): + return nested_regex, nested_benign + + raise SystemExit( + "Could not find regex DBs. Checked:\n" + f"- {direct_regex} and {direct_benign}\n" + f"- {nested_regex} and {nested_benign}" + ) + + +def load_benign_values(benign_db_path: Path) -> dict[str, list[str]]: + benign_values = {regex_type: [] for regex_type in REGEX_TYPES} + with sqlite3.connect(benign_db_path) as conn: + rows = conn.execute( + "SELECT regex_type, value FROM benign_strings ORDER BY id ASC" + ) + for regex_type, value in rows: + benign_values.setdefault(regex_type, []).append(str(value or "")) + return benign_values + + +def load_accepted_regexes( + regex_db_path: Path, regex_types: set[str] +) -> dict[str, list[dict]]: + accepted = defaultdict(list) + with sqlite3.connect(regex_db_path) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT id, regex_type, regex, regex_hash, created_at + FROM generated_regexes + WHERE status = 'accepted' + ORDER BY created_at ASC, id ASC + """ + ).fetchall() + for row in rows: + regex_type = row["regex_type"] + if regex_type not in regex_types: + continue + accepted[regex_type].append(dict(row)) + return accepted + + +def audit_regex_type( + regex_rows: list[dict], + benign_values: list[str], + threshold: float, + match_timeout_seconds: float, + progress: AuditProgressTracker | None = None, +) -> tuple[list[RegexAuditResult], list[dict]]: + flagged = [] + timed_out = [] + for row in regex_rows: + comparisons_checked = 0 + flagged_increment = 0 + timed_out_increment = 0 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + compiled = re.compile(row["regex"]) + except re.error: + if progress is not None: + progress.advance( + row["regex_type"], + comparisons=comparisons_checked, + flagged_increment=flagged_increment, + timed_out_increment=timed_out_increment, + ) + continue + + regex_features = measure_regex_specificity(row["regex"]) + best_score = 0.0 + best_value = "" + try: + with timeout_context(match_timeout_seconds): + for value in benign_values: + comparisons_checked += 1 + score = compute_match_strength(compiled, value, regex_features) + if score > best_score: + best_score = score + best_value = value + if best_score >= threshold: + flagged_increment = 1 + flagged.append( + RegexAuditResult( + id=int(row["id"]), + regex_type=row["regex_type"], + regex=row["regex"], + regex_hash=row["regex_hash"], + created_at=float(row["created_at"]), + strongest_benign_score=best_score, + strongest_benign_value=best_value, + ) + ) + break + except TimeoutError: + timed_out_increment = 1 + timed_out.append( + { + "id": int(row["id"]), + "regex_type": row["regex_type"], + "regex": row["regex"], + "regex_hash": row["regex_hash"], + "created_at": float(row["created_at"]), + "comparisons_checked": comparisons_checked, + } + ) + if progress is not None: + progress.advance( + row["regex_type"], + comparisons=comparisons_checked, + flagged_increment=flagged_increment, + timed_out_increment=timed_out_increment, + ) + flagged.sort( + key=lambda item: ( + item.regex_type, + item.strongest_benign_score, + item.created_at, + item.id, + ), + reverse=True, + ) + timed_out.sort( + key=lambda item: ( + item["regex_type"], + item["created_at"], + item["id"], + ), + reverse=True, + ) + return flagged, timed_out + + +def backup_regex_db(regex_db_path: Path) -> Path: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_path = regex_db_path.with_suffix(regex_db_path.suffix + f".bak.{timestamp}") + shutil.copy2(regex_db_path, backup_path) + return backup_path + + +def delete_flagged_regexes( + regex_db_path: Path, flagged_results: list[RegexAuditResult], vacuum: bool +) -> int: + ids = [result.id for result in flagged_results] + if not ids: + return 0 + + placeholders = ",".join("?" for _ in ids) + with sqlite3.connect(regex_db_path) as conn: + cursor = conn.execute( + f"DELETE FROM generated_regexes WHERE id IN ({placeholders})", + ids, + ) + deleted = int(cursor.rowcount or 0) + conn.commit() + if vacuum: + conn.execute("VACUUM") + return deleted + + +def build_summary( + regex_db_path: Path, + benign_db_path: Path, + threshold: float, + regex_types: list[str], + accepted_by_type: dict[str, list[dict]], + flagged_by_type: dict[str, list[RegexAuditResult]], + timed_out_by_type: dict[str, list[dict]], + limit: int, + deleted: int, + backup_path: Path | None, + match_timeout_seconds: float, +) -> dict: + summary_types = {} + for regex_type in regex_types: + flagged_rows = flagged_by_type.get(regex_type, []) + timed_out_rows = timed_out_by_type.get(regex_type, []) + summary_types[regex_type] = { + "accepted_count": len(accepted_by_type.get(regex_type, [])), + "flagged_count": len(flagged_rows), + "timed_out_count": len(timed_out_rows), + "examples": [ + { + **asdict(result), + "created_at_iso": datetime.fromtimestamp( + result.created_at, tz=timezone.utc + ).isoformat(), + } + for result in flagged_rows[:limit] + ], + "timed_out_examples": [ + { + **row, + "created_at_iso": datetime.fromtimestamp( + row["created_at"], tz=timezone.utc + ).isoformat(), + } + for row in timed_out_rows[:limit] + ], + } + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "regex_db_path": str(regex_db_path), + "benign_db_path": str(benign_db_path), + "threshold": threshold, + "match_timeout_seconds": match_timeout_seconds, + "regex_types": regex_types, + "deleted_count": deleted, + "backup_path": str(backup_path) if backup_path else "", + "totals": { + "accepted_count": sum( + len(accepted_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + "flagged_count": sum( + len(flagged_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + "timed_out_count": sum( + len(timed_out_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + }, + "types": summary_types, + } + + +def print_summary(summary: dict, delete_mode: bool): + action = "Deleted" if delete_mode else "Flagged" + print( + f"Threshold: {summary['threshold']:.2f}\n" + f"Match timeout per regex: {summary['match_timeout_seconds']:.2f}s\n" + f"Regex DB: {summary['regex_db_path']}\n" + f"Benign DB: {summary['benign_db_path']}\n" + f"Accepted rows scanned: {summary['totals']['accepted_count']}\n" + f"{action} rows: {summary['totals']['flagged_count']}\n" + f"Timed-out rows skipped: {summary['totals']['timed_out_count']}" + ) + print( + "Accepted means rows currently stored in generated_regexes.sqlite " + "with status='accepted'." + ) + if delete_mode: + print( + "Deleted means accepted rows whose strongest benign match score " + "met or exceeded the threshold and were removed." + ) + else: + print( + "Flagged means accepted rows whose strongest benign match score " + "meets or exceeds the threshold against the current benign corpus." + ) + if summary.get("backup_path"): + print(f"Backup: {summary['backup_path']}") + + for regex_type in summary["regex_types"]: + row = summary["types"][regex_type] + print( + f"\n[{regex_type}] accepted={row['accepted_count']} " + f"flagged={row['flagged_count']} " + f"timed_out={row['timed_out_count']}" + ) + for example in row["examples"]: + print( + " " + f"score={example['strongest_benign_score']:.2f} " + f"value={example['strongest_benign_value']} " + f"created_at={example['created_at_iso']} " + f"regex={example['regex']}" + ) + for example in row["timed_out_examples"]: + print( + " " + "timed_out " + f"after_cmp={example['comparisons_checked']} " + f"created_at={example['created_at_iso']} " + f"regex={example['regex']}" + ) + + +def main(): + args = parse_args() + regex_db_path, benign_db_path = resolve_paths(args) + threshold = ( + float(args.threshold) if args.threshold is not None else default_threshold() + ) + match_timeout_seconds = ( + float(args.match_timeout_seconds) + if args.match_timeout_seconds is not None + else default_match_timeout() + ) + regex_types = sorted(set(args.regex_type or REGEX_TYPES)) + + benign_values = load_benign_values(benign_db_path) + accepted_by_type = load_accepted_regexes(regex_db_path, set(regex_types)) + progress = AuditProgressTracker( + total_regexes=sum( + len(accepted_by_type.get(regex_type, [])) for regex_type in regex_types + ), + totals_by_type={ + regex_type: len(accepted_by_type.get(regex_type, [])) + for regex_type in regex_types + }, + ) + progress.start() + flagged_by_type = {} + timed_out_by_type = {} + for regex_type in regex_types: + flagged_rows, timed_out_rows = audit_regex_type( + accepted_by_type.get(regex_type, []), + benign_values.get(regex_type, []), + threshold, + match_timeout_seconds, + progress=progress, + ) + flagged_by_type[regex_type] = flagged_rows + timed_out_by_type[regex_type] = timed_out_rows + progress.finish() + + backup_path = None + deleted = 0 + flagged_results = [ + result + for regex_type in regex_types + for result in flagged_by_type.get(regex_type, []) + ] + if args.delete and flagged_results: + if not args.no_backup: + backup_path = backup_regex_db(regex_db_path) + deleted = delete_flagged_regexes(regex_db_path, flagged_results, args.vacuum) + + summary = build_summary( + regex_db_path=regex_db_path, + benign_db_path=benign_db_path, + threshold=threshold, + regex_types=regex_types, + accepted_by_type=accepted_by_type, + flagged_by_type=flagged_by_type, + timed_out_by_type=timed_out_by_type, + limit=max(0, args.limit), + deleted=deleted, + backup_path=backup_path, + match_timeout_seconds=match_timeout_seconds, + ) + print_summary(summary, delete_mode=args.delete) + + if args.output_json: + output_path = Path(args.output_json).expanduser() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() From 4e529b84b5f48e4f302e876540830604bb2302ec Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:52 +0200 Subject: [PATCH 0674/1100] feat: enhance report HTML assertions for regex and co-stimulation states --- tests/unit/modules/t_cell/test_analyze_t_cell.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index ba4812dd59..6767cd269b 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -351,7 +351,11 @@ def test_build_report_payload_and_html(tmp_path): assert "Quick Summary" in html assert "Decision Trace" in html assert "T Cell State Machine" in html - assert "regex match" in html + assert "accepted regex match" in html + assert "no accepted regex match" in html + assert "stays mature" in html + assert "co-stimulation below threshold" in html + assert "no co-stimulation timeout" in html assert "current cells:" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html From 34caa6a9bebf156127b4b53147cd7d57912a3e19 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sun, 19 Apr 2026 09:56:07 +0200 Subject: [PATCH 0675/1100] feat: enable decision trace mode for T Cell responder module --- config/slips.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/slips.yaml b/config/slips.yaml index 427cf8e39d..e8254414be 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -391,7 +391,7 @@ t_cell: # off = disabled # transitions = write detailed traces only when a state transition happens # all = also trace waiting evaluations - decision_trace_mode: off + decision_trace_mode: on # Separate trace file used only when decision_trace_mode is not off. # This path is always resolved inside the selected output directory for the From 900dcc72694ca4acb748b44f4194d7aec52d84a3 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sun, 19 Apr 2026 09:56:25 +0200 Subject: [PATCH 0676/1100] Implement feature X to enhance user experience and fix bug Y in module Z --- modules/t_cell/analyze_t_cell.py | 1779 +++++++++++++++++++++++++++++- 1 file changed, 1732 insertions(+), 47 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index d0facc12e5..9ba9a80038 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -62,6 +62,29 @@ "co_stimulation": "waiting for co-stimulation", "context": "waiting for context", } +DEFAULT_COSTIM_WEIGHTS = { + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, +} +DEFAULT_DOC_CONFIG = { + "anergy_ttl_seconds": 21600.0, + "related_lookback_seconds": 3600.0, + "related_pamps_saturation": 5.0, + "danger_saturation": 2.5, + "damp_danger_weight": 1.5, + "co_stimulation_threshold": 0.65, + "co_stimulation_weights": DEFAULT_COSTIM_WEIGHTS, + "novelty_window_seconds": 86400.0, + "context_recent_window_seconds": 1800.0, + "effector_threshold": 0.70, + "effector_min_related_count": 4, + "effector_cooldown_seconds": 1800.0, + "memory_threshold": 0.60, + "memory_trend_ratio_max": 0.60, + "memory_min_related_count": 3, + "state_wait_timeout_seconds": 3600.0, +} def parse_args() -> argparse.Namespace: @@ -451,6 +474,54 @@ def safe_div(num: float, den: float) -> float: return num / den +def normalize_costim_weights(weights: Any) -> dict[str, float]: + if not isinstance(weights, dict): + weights = {} + sanitized = {} + for key, default_value in DEFAULT_COSTIM_WEIGHTS.items(): + raw_value = weights.get(key, default_value) + try: + raw_value = float(raw_value) + except (TypeError, ValueError): + raw_value = default_value + sanitized[key] = max(0.0, raw_value) + + total = sum(sanitized.values()) + if total <= 0: + total = sum(DEFAULT_COSTIM_WEIGHTS.values()) + sanitized = DEFAULT_COSTIM_WEIGHTS.copy() + return {key: value / total for key, value in sanitized.items()} + + +def coerce_time_window_width(raw_value: Any) -> float: + if raw_value in (None, ""): + return float(DEFAULT_DOC_CONFIG["state_wait_timeout_seconds"]) + try: + return float(raw_value) + except (TypeError, ValueError): + text = str(raw_value) + if "only_one_tw" in text: + return 9999999999.0 + return float(DEFAULT_DOC_CONFIG["state_wait_timeout_seconds"]) + + +def report_config_with_defaults(report: dict) -> dict: + config = dict(report.get("config") or {}) + merged = {} + for key, default_value in DEFAULT_DOC_CONFIG.items(): + raw_value = config.get(key, default_value) + if key == "co_stimulation_weights": + merged[key] = normalize_costim_weights(raw_value) + continue + if key == "state_wait_timeout_seconds": + merged[key] = coerce_time_window_width(raw_value) + continue + if raw_value in (None, "", {}): + raw_value = default_value + merged[key] = raw_value + return merged + + def build_findings(report: dict) -> list[str]: totals = report["totals"] categories = report["observation_categories"] @@ -612,6 +683,523 @@ def bucket_items( } +def trace_row_cell_key(entry: dict) -> str: + if entry.get("cell_key"): + return str(entry.get("cell_key")) + candidate = entry.get("candidate") or {} + responsible_ip = str(entry.get("responsible_ip") or "") + regex_type = str(candidate.get("regex_type") or "") + antigen_value = str(candidate.get("value") or "") + if responsible_ip and regex_type and antigen_value: + return f"{responsible_ip}|{regex_type}|{antigen_value}" + return "" + + +def describe_current_evidence(current_evidence: dict | None) -> str: + current_evidence = current_evidence or {} + evidence_id = current_evidence.get("evidence_id") or "n/a" + evidence_type = current_evidence.get("evidence_type") or "unknown" + signal = current_evidence.get("signal") or "unknown" + confidence = format_float(current_evidence.get("confidence")) + threat_level = current_evidence.get("threat_level") or "unknown" + threat_level_value = format_float(current_evidence.get("threat_level_value")) + danger = format_float(current_evidence.get("danger_contribution")) + observation_id = current_evidence.get("observation_id") + observation_part = ( + f"obs={observation_id} | " if observation_id not in (None, "") else "" + ) + return ( + f"{observation_part}eid={evidence_id} | {evidence_type} | {signal} | " + f"conf={confidence} | threat={threat_level} ({threat_level_value}) | " + f"danger={danger}" + ) + + +def describe_observation_row(observation: dict | None) -> str: + observation = observation or {} + if not observation: + return "no linked observation row" + return ( + f"obs={observation.get('id')} | eid={observation.get('evidence_id')} | " + f"{observation.get('evidence_type')} | {observation.get('evidence_signal')} | " + f"conf={format_float(observation.get('confidence'))} | " + f"threat={observation.get('threat_level')} " + f"({format_float(observation.get('threat_level_value'))}) | " + f"antigens={summarize_antigens(observation.get('antigens') or [])} | " + f"matches={summarize_matched_regexes(observation.get('matched_regexes') or [])}" + ) + + +def describe_trace_contributor(prefix: str, contributor: dict) -> str: + relations = contributor.get("relations") or [] + relations_text = f" | relations={','.join(relations)}" if relations else "" + return ( + f"{prefix}: obs={contributor.get('observation_id')} | " + f"eid={contributor.get('evidence_id')} | {contributor.get('evidence_type')} | " + f"{contributor.get('signal')} | conf={format_float(contributor.get('confidence'))} | " + f"threat={contributor.get('threat_level')} " + f"({format_float(contributor.get('threat_level_value'))}) | " + f"danger={format_float(contributor.get('danger_contribution'))}" + f"{relations_text}" + ) + + +def summarize_lines(lines: list[str], fallback: str = "n/a", limit: int = 2) -> str: + cleaned = [str(line).strip() for line in lines if str(line).strip()] + if not cleaned: + return fallback + summary = " | ".join(cleaned[:limit]) + if len(cleaned) > limit: + summary += f" | +{len(cleaned) - limit} more" + return summary + + +def generic_threshold_result(scores: dict) -> tuple[str, list[str]]: + if not isinstance(scores, dict): + return ("n/a", ["No threshold snapshot was stored for this transition."]) + + if "value" in scores and "threshold" in scores: + value = scores.get("value") + threshold = scores.get("threshold") + passed = float(value) >= float(threshold) + comparator = ">=" if passed else "<" + status = "passed" if passed else "failed" + return ( + f"{status}: {format_float(value)} {comparator} {format_float(threshold)}", + [ + f"value={format_float(value)}", + f"threshold={format_float(threshold)}", + ], + ) + + result_lines = [] + summary_bits = [] + if "effector_score" in scores and "effector_threshold" in scores: + passed = float(scores["effector_score"]) >= float(scores["effector_threshold"]) + summary_bits.append( + "effector " + + ("passed" if passed else "failed") + + f": {format_float(scores['effector_score'])} " + + (">=" if passed else "<") + + f" {format_float(scores['effector_threshold'])}" + ) + result_lines.append(summary_bits[-1]) + if "memory_score" in scores and "memory_threshold" in scores: + passed = float(scores["memory_score"]) >= float(scores["memory_threshold"]) + summary_bits.append( + "memory " + + ("passed" if passed else "failed") + + f": {format_float(scores['memory_score'])} " + + (">=" if passed else "<") + + f" {format_float(scores['memory_threshold'])}" + ) + result_lines.append(summary_bits[-1]) + if not summary_bits: + return ("n/a", ["No threshold keys were stored in this score snapshot."]) + return (" | ".join(summary_bits), result_lines) + + +def build_trace_threshold_result(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + action = entry.get("action") or "" + if stage == "co_stimulation": + value = formula.get("value") + threshold = formula.get("threshold") + if value is None or threshold is None: + return ("n/a", ["Missing co-stimulation value or threshold."]) + passed = float(value) >= float(threshold) + comparator = ">=" if passed else "<" + return ( + f"{'passed' if passed else 'failed'}: " + f"{format_float(value)} {comparator} {format_float(threshold)}", + [ + f"action={action}", + f"value={format_float(value)}", + f"threshold={format_float(threshold)}", + ], + ) + + if stage == "context": + decision = formula.get("decision") or {} + effector = bool(decision.get("effector")) + memory = bool(decision.get("memory")) + effector_score = formula.get("effector_score") + effector_threshold = formula.get("effector_threshold") + memory_score = formula.get("memory_score") + memory_threshold = formula.get("memory_threshold") + summary = ( + f"effector={'yes' if effector else 'no'} " + f"({format_float(effector_score)} / {format_float(effector_threshold)}) | " + f"memory={'yes' if memory else 'no'} " + f"({format_float(memory_score)} / {format_float(memory_threshold)})" + ) + return ( + summary, + [ + f"action={action}", + f"effector decision={'passed' if effector else 'failed'}", + f"memory decision={'passed' if memory else 'failed'}", + f"effector_score={format_float(effector_score)} threshold={format_float(effector_threshold)}", + f"memory_score={format_float(memory_score)} threshold={format_float(memory_threshold)}", + ], + ) + return ("n/a", ["No threshold formatter for this trace stage."]) + + +def build_trace_considered_evidence(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + lines = [] + current_evidence = entry.get("current_evidence") or {} + if current_evidence: + lines.append("current: " + describe_current_evidence(current_evidence)) + + components = formula.get("components") or {} + if stage == "co_stimulation": + related = (components.get("related_pamps") or {}).get("contributors") or [] + danger = components.get("danger") or {} + pamp_contributors = danger.get("pamp_contributors") or [] + damp_contributors = danger.get("damp_contributors") or [] + for contributor in related: + lines.append(describe_trace_contributor("related_pamp", contributor)) + for contributor in pamp_contributors: + lines.append(describe_trace_contributor("danger_pamp", contributor)) + for contributor in damp_contributors: + lines.append(describe_trace_contributor("danger_damp", contributor)) + elif stage == "context": + recent_related = (components.get("recent_related") or {}).get( + "contributors" + ) or [] + recent_pressure = components.get("recent_pressure") or {} + previous_pressure = components.get("previous_pressure") or {} + for contributor in recent_related: + lines.append(describe_trace_contributor("recent_related", contributor)) + for contributor in recent_pressure.get("pamp_contributors") or []: + lines.append(describe_trace_contributor("recent_pressure_pamp", contributor)) + for contributor in recent_pressure.get("damp_contributors") or []: + lines.append(describe_trace_contributor("recent_pressure_damp", contributor)) + for contributor in previous_pressure.get("pamp_contributors") or []: + lines.append(describe_trace_contributor("previous_pressure_pamp", contributor)) + for contributor in previous_pressure.get("damp_contributors") or []: + lines.append(describe_trace_contributor("previous_pressure_damp", contributor)) + + if not lines: + lines.append("No contributor evidence snapshot was stored for this event.") + return (summarize_lines(lines, fallback="no stored evidence inputs"), lines) + + +def build_trace_computation_lines(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + if stage == "co_stimulation": + components = formula.get("components") or {} + confidence = components.get("confidence") or {} + related = components.get("related_pamps") or {} + danger = components.get("danger") or {} + lines = [ + f"value={format_float(formula.get('value'))}", + f"threshold={format_float(formula.get('threshold'))}", + ( + "confidence: value=" + f"{format_float(confidence.get('value'))} weighted=" + f"{format_float(confidence.get('weighted'))}" + ), + ( + "related_pamps: count=" + f"{related.get('count', 'n/a')} saturation=" + f"{format_float(related.get('saturation'))} score=" + f"{format_float(related.get('score'))} weighted=" + f"{format_float(related.get('weighted'))}" + ), + ( + "danger: score=" + f"{format_float(danger.get('score'))} weighted=" + f"{format_float(danger.get('weighted'))} pamp_score=" + f"{format_float(danger.get('pamp_score'))} damp_score=" + f"{format_float(danger.get('damp_score'))} damp_weight=" + f"{format_float(danger.get('damp_weight'))} saturation=" + f"{format_float(danger.get('danger_saturation'))}" + ), + ] + return (summarize_trace_formula(formula, stage), lines) + + if stage == "context": + components = formula.get("components") or {} + novelty = components.get("novelty") or {} + recent_related = components.get("recent_related") or {} + recent_pressure = components.get("recent_pressure") or {} + previous_pressure = components.get("previous_pressure") or {} + lines = [ + ( + "effector_score=" + f"{format_float(formula.get('effector_score'))} threshold=" + f"{format_float(formula.get('effector_threshold'))}" + ), + ( + "memory_score=" + f"{format_float(formula.get('memory_score'))} threshold=" + f"{format_float(formula.get('memory_threshold'))}" + ), + ( + "decision flags: effector=" + f"{'yes' if (formula.get('decision') or {}).get('effector') else 'no'} " + "memory=" + f"{'yes' if (formula.get('decision') or {}).get('memory') else 'no'}" + ), + ( + "novelty: score=" + f"{format_float(novelty.get('score'))} has_memory=" + f"{'yes' if novelty.get('has_memory_for_regex') else 'no'} " + "recent_activity=" + f"{'yes' if novelty.get('has_recent_regex_activity') else 'no'}" + ), + ( + "recent_related: count=" + f"{recent_related.get('count', 'n/a')} saturation=" + f"{format_float(recent_related.get('saturation'))} score=" + f"{format_float(recent_related.get('score'))}" + ), + ( + "recent_pressure: combined=" + f"{format_float(recent_pressure.get('combined_score'))} pamp=" + f"{format_float(recent_pressure.get('pamp_score'))} damp=" + f"{format_float(recent_pressure.get('damp_score'))} " + "raw_pamp=" + f"{format_float(recent_pressure.get('pamp_total_raw'))} raw_damp=" + f"{format_float(recent_pressure.get('damp_total_raw'))}" + ), + ( + "previous_pressure: combined=" + f"{format_float(previous_pressure.get('combined_score'))} pamp=" + f"{format_float(previous_pressure.get('pamp_score'))} damp=" + f"{format_float(previous_pressure.get('damp_score'))} " + "raw_pamp=" + f"{format_float(previous_pressure.get('pamp_total_raw'))} raw_damp=" + f"{format_float(previous_pressure.get('damp_total_raw'))}" + ), + f"trend_ratio={format_float(components.get('trend_ratio'))}", + f"decrease_score={format_float(components.get('decrease_score'))}", + f"familiarity_score={format_float(components.get('familiarity_score'))}", + f"stability_score={format_float(components.get('stability_score'))}", + ] + return (summarize_trace_formula(formula, stage), lines) + + return ("n/a", ["No computation formatter for this trace stage."]) + + +def build_transition_computation_lines(transition: dict) -> tuple[str, list[str]]: + scores = transition.get("scores") or {} + if not scores: + return ("no score snapshot", ["This transition stored no score payload."]) + lines = [f"{key}={format_float(value)}" for key, value in sorted(scores.items())] + return (summarize_lines(lines, fallback="score snapshot"), lines) + + +def build_transition_event( + transition: dict, observations_by_id: dict[int, dict] +) -> dict: + observation = observations_by_id.get(int(transition.get("observation_id") or 0), {}) + threshold_summary, threshold_lines = generic_threshold_result( + transition.get("scores") or {} + ) + computation_summary, computation_lines = build_transition_computation_lines( + transition + ) + evidence_lines = [describe_observation_row(observation)] + return { + "ts": transition.get("created_at"), + "wall": ts_to_iso(transition.get("created_at")), + "source": "State transition", + "step": transition.get("reason") or "transition", + "stage": "transition", + "state_path": ( + f"{state_label(transition.get('from_state'))} → " + f"{state_label(transition.get('to_state'))}" + ), + "evidence_id": transition.get("evidence_id") or "", + "threshold_summary": threshold_summary, + "threshold_lines": threshold_lines, + "considered_summary": summarize_lines(evidence_lines), + "considered_lines": evidence_lines, + "computation_summary": computation_summary, + "computation_lines": computation_lines, + "priority": 2, + } + + +def build_trace_event(entry: dict) -> dict: + threshold_summary, threshold_lines = build_trace_threshold_result(entry) + considered_summary, considered_lines = build_trace_considered_evidence(entry) + computation_summary, computation_lines = build_trace_computation_lines(entry) + current_evidence = entry.get("current_evidence") or {} + evidence_id = current_evidence.get("evidence_id") or "" + evidence_type = current_evidence.get("evidence_type") or "" + signal = current_evidence.get("signal") or "" + if evidence_type or signal: + evidence_label = f"{evidence_id} | {evidence_type} | {signal}".strip(" |") + else: + evidence_label = evidence_id or "n/a" + return { + "ts": entry.get("_ts"), + "wall": entry.get("ts") or ts_to_iso(entry.get("_ts")), + "source": "Decision trace", + "step": f"{entry.get('stage') or 'trace'}: {entry.get('action') or 'event'}", + "stage": entry.get("stage") or "trace", + "state_path": ( + f"{entry.get('from_state') or 'n/a'} → {entry.get('to_state') or 'n/a'}" + ), + "evidence_id": evidence_label, + "threshold_summary": threshold_summary, + "threshold_lines": threshold_lines, + "considered_summary": considered_summary, + "considered_lines": considered_lines, + "computation_summary": computation_summary, + "computation_lines": computation_lines, + "priority": 1, + } + + +def build_life_path( + transitions_for_cell: list[dict], current_state_label: str | None +) -> str: + ordered = sorted( + transitions_for_cell, + key=lambda item: (float(item.get("created_at") or 0.0), int(item.get("id") or 0)), + ) + states = [] + for transition in ordered: + from_label = state_label(transition.get("from_state")) + to_label = state_label(transition.get("to_state")) + if not states: + states.append(from_label) + if states[-1] != from_label: + states.append(from_label) + if states[-1] != to_label: + states.append(to_label) + if not states and current_state_label: + states = [current_state_label] + elif current_state_label and states[-1] != current_state_label: + states.append(current_state_label) + return " → ".join(states) if states else "no recorded state changes" + + +def build_cell_histories( + observations: list[dict], + cells: list[dict], + transitions: list[dict], + trace_rows: list[dict], +) -> list[dict]: + observations_by_id = { + int(observation["id"]): observation + for observation in observations + if observation.get("id") is not None + } + cells_by_key = { + str(cell.get("cell_key")): cell + for cell in cells + if cell.get("cell_key") + } + transitions_by_cell: dict[str, list[dict]] = defaultdict(list) + for transition in transitions: + cell_key = str(transition.get("cell_key") or "") + if cell_key: + transitions_by_cell[cell_key].append(transition) + + traces_by_cell: dict[str, list[dict]] = defaultdict(list) + for entry in trace_rows: + cell_key = trace_row_cell_key(entry) + if cell_key: + traces_by_cell[cell_key].append(entry) + + cell_keys = set(cells_by_key) | set(transitions_by_cell) | set(traces_by_cell) + histories = [] + for cell_key in sorted(cell_keys): + cell = cells_by_key.get(cell_key, {}) + cell_transitions = transitions_by_cell.get(cell_key, []) + cell_traces = traces_by_cell.get(cell_key, []) + + events = [build_trace_event(entry) for entry in cell_traces] + events.extend( + build_transition_event(transition, observations_by_id) + for transition in cell_transitions + ) + events.sort( + key=lambda item: ( + item.get("ts") is None, + float(item.get("ts") or 0.0), + int(item.get("priority") or 9), + item.get("step") or "", + ) + ) + + current_state_label = state_label(cell.get("state")) if cell else None + waiting_label = cell_waiting_label(cell) if cell else "" + first_ts_candidates = [ + float(item.get("ts")) + for item in events + if item.get("ts") is not None + ] + if cell.get("created_at") is not None: + first_ts_candidates.append(float(cell.get("created_at"))) + last_ts_candidates = [ + float(item.get("ts")) + for item in events + if item.get("ts") is not None + ] + if cell.get("updated_at") is not None: + last_ts_candidates.append(float(cell.get("updated_at"))) + first_seen = min(first_ts_candidates) if first_ts_candidates else None + last_seen = max(last_ts_candidates) if last_ts_candidates else None + current_state_display = current_state_label or "unknown" + if waiting_label: + current_state_display += f" ({waiting_label})" + + histories.append( + { + "cell_key": cell_key, + "responsible_ip": cell.get("responsible_ip") + or ( + cell_transitions[0].get("profile_ip") + if cell_transitions + else (cell_traces[0].get("responsible_ip") if cell_traces else "") + ), + "regex_type": cell.get("regex_type") + or ( + cell_transitions[0].get("regex_type") + if cell_transitions + else ((cell_traces[0].get("candidate") or {}).get("regex_type", "")) + ), + "antigen_value": cell.get("antigen_value") + or ( + cell_transitions[0].get("antigen_value") + if cell_transitions + else ((cell_traces[0].get("candidate") or {}).get("value", "")) + ), + "matched_value": cell.get("matched_value") + or ( + cell_transitions[-1].get("matched_value") + if cell_transitions + else ((cell_traces[-1].get("match") or {}).get("value", "")) + ), + "current_state": current_state_display, + "current_state_class": state_class(cell.get("state")) + if cell + else "state-unknown", + "waiting_label": waiting_label, + "life_path": build_life_path(cell_transitions, current_state_label), + "first_seen": ts_to_iso(first_seen), + "last_seen": ts_to_iso(last_seen), + "event_count": len(events), + "transition_count": len(cell_transitions), + "trace_count": len(cell_traces), + "events": events, + } + ) + + return histories + + def build_report_payload( run_output_dir: Path, max_observations: int = 200, @@ -631,7 +1219,9 @@ def build_report_payload( memories = db_records["memories"] log_data = load_log_entries(log_path, max_log_lines) trace_rows = load_trace_entries(trace_path) - config = load_yaml_config(metadata_path).get("t_cell", {}) + metadata = load_yaml_config(metadata_path) + config = metadata.get("t_cell", {}) + parameters = metadata.get("parameters", {}) transitions_by_observation: dict[int, list[dict]] = defaultdict(list) for transition in transitions: @@ -825,6 +1415,12 @@ def build_report_payload( } for entry in log_data["entries"][-max(1, max_log_lines) :] ] + cell_histories = build_cell_histories( + observations=observations, + cells=cells, + transitions=transitions, + trace_rows=trace_rows, + ) report = { "generated_at": now_iso(), @@ -843,11 +1439,29 @@ def build_report_payload( "log_verbosity": config.get("log_verbosity"), "decision_trace_mode": config.get("decision_trace_mode"), "related_lookback_seconds": config.get("related_lookback_seconds"), + "related_pamps_saturation": config.get("related_pamps_saturation"), + "danger_saturation": config.get("danger_saturation"), + "damp_danger_weight": config.get("damp_danger_weight"), "co_stimulation_threshold": config.get("co_stimulation_threshold"), + "co_stimulation_weights": normalize_costim_weights( + config.get("co_stimulation_weights") + ), + "novelty_window_seconds": config.get("novelty_window_seconds"), + "context_recent_window_seconds": config.get( + "context_recent_window_seconds" + ), "effector_threshold": config.get("effector_threshold"), + "effector_min_related_count": config.get( + "effector_min_related_count" + ), "memory_threshold": config.get("memory_threshold"), + "memory_trend_ratio_max": config.get("memory_trend_ratio_max"), + "memory_min_related_count": config.get("memory_min_related_count"), "anergy_ttl_seconds": config.get("anergy_ttl_seconds"), "effector_cooldown_seconds": config.get("effector_cooldown_seconds"), + "state_wait_timeout_seconds": coerce_time_window_width( + parameters.get("time_window_width") + ), }, "totals": { "observations": len(observations), @@ -893,6 +1507,7 @@ def build_report_payload( "rows": recent_trace_rows[: max(1, max_trace_rows)], "total_rows": len(trace_rows), }, + "cell_histories": cell_histories, "log": { "rows": recent_log_rows, "tail_text": "\n".join(log_data["tail"]), @@ -1430,6 +2045,723 @@ def render_pretty_json(value: Any) -> str: return escape(json.dumps(value, indent=2, sort_keys=True)) +def render_formula_box(lines: list[str]) -> str: + return ( + "
    "
    +        + escape("\n".join(lines))
    +        + "
    " + ) + + +def render_term_cards(terms: list[dict]) -> str: + return "".join( + f""" +
    +

    {escape(term['label'])}

    +

    {escape(term['formula'])}

    +

    {escape(term['description'])}

    +
    + """ + for term in terms + ) + + +def render_formula_tree_node(node: dict) -> str: + children = node.get("children") or [] + child_class = "formula-children" + if len(children) > 1: + child_class += " has-multiple" + tooltip = node.get("tooltip") or "" + formula = node.get("formula") or "" + summary = node.get("summary") or "" + children_html = "" + if children: + children_html = ( + f"
    " + + "".join( + "
    " + + render_formula_tree_node(child) + + "
    " + for child in children + ) + + "
    " + ) + return f""" +
    +
    + {escape(node.get('label', 'value'))} + {f"{escape(formula)}" if formula else ""} + {f"{escape(summary)}" if summary else ""} + {f"{escape(tooltip)}" if tooltip else ""} +
    + {children_html} +
    + """ + + +def render_formula_tree(node: dict) -> str: + return f"
    {render_formula_tree_node(node)}
    " + + +def render_decision_doc_card(card: dict) -> str: + equation_html = render_formula_box(card["equation_lines"]) + gate_html = render_formula_box(card["gate_lines"]) + term_cards_html = render_term_cards(card["terms"]) + tree_html = render_formula_tree(card["tree"]) + notes_html = "".join( + f"

    {escape(note)}

    " + for note in card.get("notes", []) + ) + return f""" +
    +
    +

    {escape(card['title'])}

    +

    {escape(card['summary'])}

    +
    +
    +
    +

    Exact Equation

    + {equation_html} +
    +
    +

    Decision Gate

    + {gate_html} +
    +
    + {notes_html} +
    + {term_cards_html} +
    +
    +
    +

    Input Tree

    +

    Hover or focus a node to see where that term comes from.

    +
    + {tree_html} +
    +
    + """ + + +def render_rule_cards(cards: list[dict]) -> str: + return "".join( + f""" +
    +

    {escape(card['title'])}

    +

    {escape(card['rule'])}

    +

    {escape(card['description'])}

    +
    + """ + for card in cards + ) + + +def render_decision_reference(report: dict) -> str: + config = report_config_with_defaults(report) + weights = config["co_stimulation_weights"] + related_lookback = format_float(config["related_lookback_seconds"]) + related_saturation = format_float(config["related_pamps_saturation"]) + danger_saturation = format_float(config["danger_saturation"]) + damp_weight = format_float(config["damp_danger_weight"]) + novelty_window = format_float(config["novelty_window_seconds"]) + context_window = format_float(config["context_recent_window_seconds"]) + wait_limit = format_float(config["state_wait_timeout_seconds"]) + co_threshold = format_float(config["co_stimulation_threshold"]) + effector_threshold = format_float(config["effector_threshold"]) + effector_min_related = str(int(config["effector_min_related_count"])) + effector_cooldown = format_float(config["effector_cooldown_seconds"]) + memory_threshold = format_float(config["memory_threshold"]) + memory_ratio_max = format_float(config["memory_trend_ratio_max"]) + memory_min_related = str(int(config["memory_min_related_count"])) + anergy_ttl = format_float(config["anergy_ttl_seconds"]) + + decision_cards = [ + { + "title": "Co-Stimulation: 1 -> 3 activation", + "summary": "This score is evaluated after antigen recognition to decide whether the cell activates.", + "equation_lines": [ + ( + "co_stimulation = " + f"{format_float(weights['confidence'])} * confidence" + ), + ( + f" + {format_float(weights['related_pamps'])} " + "* related_pamp_score" + ), + ( + f" + {format_float(weights['danger'])} " + "* profile_danger_score" + ), + ], + "gate_lines": [ + f"activate when co_stimulation >= {co_threshold}", + "otherwise stay in 1 - antigen-recognized", + f"timeout to 2 - anergic after {wait_limit}s if still below threshold", + ], + "notes": [ + f"Related PAMPs are counted over the last {related_lookback}s for the same responsible IP.", + "A related PAMP shares either the same antigen value or the same matched regex hash. The current observation is excluded from that count.", + "DAMP observations never create cells, but they do raise the mixed danger term used here.", + ], + "terms": [ + { + "label": "confidence", + "formula": "current evidence.confidence", + "description": "The confidence carried by the observation that is being evaluated right now.", + }, + { + "label": "related_pamp_score", + "formula": f"clamp01(related_pamp_count / {related_saturation})", + "description": "How much recent, related PAMP evidence reinforces the same antigen or regex identity.", + }, + { + "label": "profile_danger_score", + "formula": ( + "clamp01((pamp_raw + " + f"{damp_weight} * damp_raw) / {danger_saturation})" + ), + "description": "The mixed danger pressure for the same responsible IP, with DAMP raw danger amplified before normalization.", + }, + { + "label": "pamp_raw / damp_raw", + "formula": "sum(threat_level_value * confidence)", + "description": "Raw danger is the sum of threat level value multiplied by confidence across recent PAMP or DAMP observations.", + }, + ], + "tree": { + "label": "co_stimulation", + "formula": ( + f"{format_float(weights['confidence'])} * confidence + " + f"{format_float(weights['related_pamps'])} * related_pamp_score + " + f"{format_float(weights['danger'])} * profile_danger_score" + ), + "summary": f"Activation score. Threshold = {co_threshold}", + "tooltip": "Final co-stimulation score used for the 1 -> 3 decision.", + "children": [ + { + "label": "confidence", + "formula": "current evidence.confidence", + "summary": "Current PAMP confidence", + "tooltip": "Read directly from the observation currently being processed.", + }, + { + "label": "related_pamp_score", + "formula": f"clamp01(related_pamp_count / {related_saturation})", + "summary": "Recent related PAMP reinforcement", + "tooltip": "Normalized count of related PAMP observations in the related lookback window.", + "children": [ + { + "label": "related_pamp_count", + "formula": "count of related recent PAMPs", + "summary": "Same antigen value or same matched regex hash", + "tooltip": ( + f"Counted over the last {related_lookback}s for the same responsible IP. " + "The current observation is excluded." + ), + }, + { + "label": "related_pamps_saturation", + "formula": related_saturation, + "summary": "Count where the score saturates at 1", + "tooltip": "If the count reaches this value, related_pamp_score stops increasing.", + }, + ], + }, + { + "label": "profile_danger_score", + "formula": ( + "clamp01((pamp_raw + " + f"{damp_weight} * damp_raw) / {danger_saturation})" + ), + "summary": "Normalized mixed danger for the responsible IP", + "tooltip": "Recent PAMP and DAMP danger are combined, then clamped into the 0..1 range.", + "children": [ + { + "label": "pamp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent PAMP raw danger", + "tooltip": ( + f"Summed over PAMP observations for the same responsible IP within the last {related_lookback}s." + ), + }, + { + "label": "damp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent DAMP raw danger", + "tooltip": ( + f"Summed over DAMP observations for the same responsible IP within the last {related_lookback}s." + ), + }, + { + "label": "damp_danger_weight", + "formula": damp_weight, + "summary": "Amplifies DAMP raw danger before normalization", + "tooltip": "DAMP pressure is scaled before it is added into the mixed danger term.", + }, + { + "label": "danger_saturation", + "formula": danger_saturation, + "summary": "Raw danger amount that maps to score 1", + "tooltip": "The combined raw danger is divided by this value before clamp01 is applied.", + }, + ], + }, + ], + }, + }, + { + "title": "Context Effector: 3 -> 4 containment", + "summary": "This score evaluates whether an activated cell should escalate into an effector response.", + "equation_lines": [ + "effector_score = 0.45 * recent_pressure", + " + 0.25 * recent_related_score", + " + 0.30 * novelty_score", + ], + "gate_lines": [ + "effector = (novelty_score > 0)", + f" and (recent_related_count >= {effector_min_related})", + f" and (effector_score >= {effector_threshold})", + ], + "notes": [ + f"recent_pressure is computed over the last {context_window}s and uses the same mixed PAMP + weighted DAMP danger model as co-stimulation.", + f"novelty_score is binary: it becomes 1 only if the matched regex has no stored memory row and no recent transition activity in the last {novelty_window}s.", + f"If the cell reaches state 4, repeated containment is still gated by an effector cooldown of {effector_cooldown}s.", + ], + "terms": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "description": "The normalized mixed danger in the recent context window for the same responsible IP.", + }, + { + "label": "recent_related_score", + "formula": f"clamp01(recent_related_count / {related_saturation})", + "description": "How much recent PAMP evidence in the context window still points to the same antigen or regex identity.", + }, + { + "label": "novelty_score", + "formula": "1 if no memory and no recent regex activity else 0", + "description": "A binary novelty gate. If the regex is already familiar, the effector path is blocked immediately.", + }, + ], + "tree": { + "label": "effector_score", + "formula": "0.45 * recent_pressure + 0.25 * recent_related_score + 0.30 * novelty_score", + "summary": f"Containment score. Threshold = {effector_threshold}", + "tooltip": "Final context score used to decide whether state 3 escalates to state 4.", + "children": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger during the most recent {context_window}s window", + "tooltip": "Computed from the recent context window immediately before the current decision.", + "children": [ + { + "label": "recent_pamp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent PAMP raw danger", + "tooltip": "Summed over recent PAMP observations in the context window.", + }, + { + "label": "recent_damp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent DAMP raw danger", + "tooltip": "Summed over recent DAMP observations in the context window.", + }, + ], + }, + { + "label": "recent_related_score", + "formula": f"clamp01(recent_related_count / {related_saturation})", + "summary": "Recent supporting PAMP count normalized to 0..1", + "tooltip": "Counts related PAMP observations in the recent context window.", + "children": [ + { + "label": "recent_related_count", + "formula": "count of related recent PAMPs", + "summary": "Same antigen value or same matched regex hash", + "tooltip": ( + f"Counted only inside the recent context window of {context_window}s." + ), + }, + { + "label": "related_pamps_saturation", + "formula": related_saturation, + "summary": "Cap for the normalized related score", + "tooltip": "The count is divided by this saturation value before clamp01 is applied.", + }, + ], + }, + { + "label": "novelty_score", + "formula": "1 if no memory and no recent activity else 0", + "summary": "Binary novelty gate", + "tooltip": "Effector requires the regex to still look new for this responsible IP.", + "children": [ + { + "label": "has_memory_for_regex", + "formula": "memory row exists for regex_hash", + "summary": "If true, novelty_score becomes 0", + "tooltip": "A stored memory for the regex marks it as familiar immediately.", + }, + { + "label": "has_recent_regex_activity", + "formula": f"transition activity within {novelty_window}s", + "summary": "If true, novelty_score becomes 0", + "tooltip": "Any recent transition for the same responsible IP and regex hash removes novelty.", + }, + ], + }, + ], + }, + }, + { + "title": "Context Memory: 3 -> 5 storage", + "summary": "This score evaluates whether an activated cell should store memory instead of escalating to containment.", + "equation_lines": [ + "memory_score = 0.60 * decrease_score", + " + 0.25 * familiarity_score", + " + 0.15 * stability_score", + ], + "gate_lines": [ + "memory = (familiarity_score > 0)", + f" and (recent_related_count >= {memory_min_related})", + f" and (trend_ratio <= {memory_ratio_max})", + f" and (memory_score >= {memory_threshold})", + ], + "notes": [ + "Memory is the cooling-down path: the same pattern is no longer novel, pressure is lower than before, and enough related evidence still supports the match.", + "trend_ratio compares the recent mixed pressure window against the previous adjacent window. Lower is better for memory.", + f"If neither effector nor memory passes, the cell stays in 3 - activated until the context wait timeout of {wait_limit}s expires.", + ], + "terms": [ + { + "label": "decrease_score", + "formula": "clamp01(1 - trend_ratio)", + "description": "Rewards situations where recent pressure is clearly lower than previous pressure.", + }, + { + "label": "trend_ratio", + "formula": "recent_pressure / max(previous_pressure, 0.01)", + "description": "Measures whether the mixed danger is falling, flat, or rising between adjacent context windows.", + }, + { + "label": "familiarity_score", + "formula": "1 - novelty_score", + "description": "Memory requires the regex to already be familiar rather than novel.", + }, + { + "label": "stability_score", + "formula": f"clamp01(recent_related_count / {memory_min_related})", + "description": "Ensures there is still enough related recent evidence to justify storing memory.", + }, + ], + "tree": { + "label": "memory_score", + "formula": "0.60 * decrease_score + 0.25 * familiarity_score + 0.15 * stability_score", + "summary": f"Memory score. Threshold = {memory_threshold}", + "tooltip": "Final context score used to decide whether state 3 transitions into state 5.", + "children": [ + { + "label": "decrease_score", + "formula": "clamp01(1 - trend_ratio)", + "summary": "Higher when recent pressure is falling", + "tooltip": "A falling trend pushes the memory score up.", + "children": [ + { + "label": "trend_ratio", + "formula": "recent_pressure / max(previous_pressure, 0.01)", + "summary": f"Must stay <= {memory_ratio_max} for memory", + "tooltip": "Compares the most recent context window against the immediately preceding one.", + "children": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger over the last {context_window}s", + "tooltip": "Same recent pressure value also used by effector_score.", + }, + { + "label": "previous_pressure", + "formula": ( + "clamp01((previous_pamp_raw + " + f"{damp_weight} * previous_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger over the previous {context_window}s window", + "tooltip": "Computed over the context window immediately before the recent one.", + }, + ], + } + ], + }, + { + "label": "familiarity_score", + "formula": "1 - novelty_score", + "summary": "Higher when the regex is already familiar", + "tooltip": "Memory is only allowed once novelty has disappeared.", + "children": [ + { + "label": "novelty_score", + "formula": "1 if no memory and no recent activity else 0", + "summary": "Same novelty gate used by the effector path", + "tooltip": "If novelty_score stays 1, familiarity_score stays 0 and memory fails.", + } + ], + }, + { + "label": "stability_score", + "formula": f"clamp01(recent_related_count / {memory_min_related})", + "summary": "Recent evidence stability", + "tooltip": "Memory still requires enough related recent PAMPs to support the pattern.", + "children": [ + { + "label": "recent_related_count", + "formula": "count of related recent PAMPs", + "summary": f"Must stay >= {memory_min_related}", + "tooltip": "Related means same antigen value or same matched regex hash in the recent context window.", + } + ], + }, + ], + }, + }, + ] + + rule_cards = [ + { + "title": "Recognition", + "rule": "0 -> 1 when a PAMP has an extracted antigen and an accepted regex match", + "description": "If a PAMP has antigens but no accepted regex match, the cell instead goes 0 -> 2 and becomes anergic.", + }, + { + "title": "Anergy Expiry", + "rule": f"2 -> 0 when anergy_ttl_seconds ({anergy_ttl}s) has elapsed", + "description": "Once the anergy TTL expires, the cell returns to mature and can be evaluated again.", + }, + { + "title": "Co-Stimulation Timeout", + "rule": f"1 -> 2 when the co-stimulation wait reaches {wait_limit}s", + "description": "The cell can keep waiting in 1 - antigen-recognized while later PAMP or DAMP evidence reevaluates the score, but only for one configured Slips time window.", + }, + { + "title": "Context Timeout", + "rule": f"3 -> 0 when the context wait reaches {wait_limit}s", + "description": "If neither effector nor memory passes before the waiting window ends, the cell falls back to 0 - mature.", + }, + { + "title": "Effector Cooldown", + "rule": f"4 -> 4 suppress repeated containment until {effector_cooldown}s passes", + "description": "The state can stay effector while repeated blocking publications are suppressed by cooldown.", + }, + { + "title": "Memory Retention", + "rule": "5 -> 5 keep the memory state on later matching evidence", + "description": "Once memory is stored for that cell, later hits retain state 5 without writing repeated memory_stored events.", + }, + ] + + decision_cards_html = "".join( + render_decision_doc_card(card) for card in decision_cards + ) + rule_cards_html = render_rule_cards(rule_cards) + return f""" +
    +
    +

    Decision Reference

    +

    Bottom-of-report explanation of how the T Cell equations and branch conditions are computed.

    +
    +

    + This section documents the exact values, thresholds, and helper rules used by the report and by the T Cell module decision logic. + Normalization uses clamp01(x) = max(0, min(1, x)). Hover or focus a node in each tree to inspect where that term comes from. +

    +
    + {decision_cards_html} +
    +
    +
    +

    Rule-Based Decisions

    +

    These branches are not weighted equations, but they still change state or suppress actions.

    +
    +
    + {rule_cards_html} +
    +
    +
    + """ + + +def render_history_details(summary: str, lines: list[str]) -> str: + details_body = escape("\n".join(lines or ["n/a"])) + return ( + f"
    {escape(summary)}" + f"
    {details_body}
    " + ) + + +def render_history_event_table(events: list[dict]) -> str: + if not events: + return '

    No history events were recorded for this T cell.

    ' + + head = "".join( + f"{escape(column)}" + for column in [ + "When", + "Source", + "Step", + "State path", + "Evidence", + "Threshold result", + "Evidence considered", + "Computation", + ] + ) + rows = [] + for event in events: + row_cells = [ + escape(event.get("wall") or "n/a"), + escape(event.get("source") or "unknown"), + escape(event.get("step") or "event"), + escape(event.get("state_path") or "n/a"), + escape(event.get("evidence_id") or "n/a"), + render_history_details( + event.get("threshold_summary") or "n/a", + event.get("threshold_lines") or [], + ), + render_history_details( + event.get("considered_summary") or "n/a", + event.get("considered_lines") or [], + ), + render_history_details( + event.get("computation_summary") or "n/a", + event.get("computation_lines") or [], + ), + ] + rows.append( + "" + + "".join(f"{cell}" for cell in row_cells) + + "" + ) + return ( + "
    " + "" + f"{head}" + f"{''.join(rows)}
    " + ) + + +def render_cell_histories(report: dict) -> str: + histories = report.get("cell_histories") or [] + if not histories: + return """ +
    +

    T Cell Histories

    +

    No T-cell histories were available for this run.

    +
    + """ + + index_rows = [ + { + "Responsible": escape(item.get("responsible_ip") or ""), + "Cell": escape(shorten(item.get("cell_key") or "", 64)), + "Current state": render_badge( + item.get("current_state") or "unknown", + item.get("current_state_class") or "state-unknown", + ), + "Life path": escape(shorten(item.get("life_path") or "", 88)), + "Events": escape(str(item.get("event_count") or 0)), + } + for item in histories + ] + index_table = render_simple_table( + ["Responsible", "Cell", "Current state", "Life path", "Events"], + index_rows, + "No T-cell history index available.", + ) + + trace_mode = (report.get("config") or {}).get("decision_trace_mode") + if trace_mode in (None, "", {}): + trace_note = ( + "Decision trace configuration was not found in metadata, so histories rely on whatever trace rows and transitions were stored." + ) + elif str(trace_mode).lower() in {"0", "off"}: + trace_note = ( + "Decision trace was off for this run, so histories can only show state transitions and any score snapshots saved with those transitions." + ) + elif str(trace_mode).lower() in {"1", "transitions"}: + trace_note = ( + "Decision trace was limited to transition events, so waiting reevaluations may be missing from the lifecycle view." + ) + else: + trace_note = ( + "Decision trace was fully enabled, so histories include both state changes and intermediate decision evaluations when available." + ) + + history_cards = [] + for index, item in enumerate(histories): + title = ( + f"{item.get('responsible_ip') or 'unknown'} | " + f"{item.get('regex_type') or 'unknown'}:{item.get('antigen_value') or ''}" + ) + meta_bits = [ + f"current={item.get('current_state') or 'unknown'}", + f"life path={item.get('life_path') or 'n/a'}", + f"first seen={item.get('first_seen') or 'n/a'}", + f"last seen={item.get('last_seen') or 'n/a'}", + f"events={item.get('event_count') or 0}", + f"transitions={item.get('transition_count') or 0}", + f"trace rows={item.get('trace_count') or 0}", + ] + table_html = render_history_event_table(item.get("events") or []) + history_cards.append( + f""" +
    + +
    +
    +

    T Cell

    +

    {escape(title)}

    +

    {escape(' | '.join(meta_bits))}

    +
    +
    + {render_badge(item.get("current_state") or "unknown", item.get("current_state_class") or "state-unknown")} +
    +
    +
    +
    +

    Cell key: {escape(item.get('cell_key') or '')}

    +

    Matched value: {escape(item.get('matched_value') or 'n/a')}

    + {table_html} +
    +
    + """ + ) + + return f""" +
    +
    +

    T Cell Histories

    +

    Chronological lifecycle view for each cell, combining stored state transitions with decision-trace computations.

    +
    +

    {escape(trace_note)}

    +
    +

    History Index

    + {index_table} +
    +
    + {''.join(history_cards)} +
    +
    + """ + + def render_html(report: dict) -> str: findings_html = "".join( f"
  • {escape(item)}
  • " for item in report.get("findings", []) @@ -1633,6 +2965,8 @@ def render_html(report: dict) -> str: TRACE_STAGE_COLORS, ) state_machine_graph = render_state_machine_graph(report) + decision_reference = render_decision_reference(report) + histories_section = render_cell_histories(report) return f""" @@ -1699,13 +3033,52 @@ def render_html(report: dict) -> str: font-size: 0.80rem; word-break: break-all; }} - .summary-grid, .panel-grid, .stats-grid {{ - display: grid; - gap: 14px; - }} - .stats-grid {{ - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - }} + .summary-grid, .panel-grid, .stats-grid {{ + display: grid; + gap: 14px; + }} + .tab-strip {{ + display: inline-flex; + gap: 8px; + margin: 16px 0 4px; + padding: 6px; + border-radius: 999px; + background: rgba(255, 253, 248, 0.82); + border: 1px solid rgba(123, 83, 44, 0.12); + box-shadow: 0 12px 26px rgba(66, 43, 17, 0.07); + position: sticky; + top: 10px; + z-index: 8; + backdrop-filter: blur(8px); + }} + .tab-button {{ + border: 0; + border-radius: 999px; + padding: 10px 16px; + background: transparent; + color: var(--muted); + font: inherit; + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; + }} + .tab-button:hover {{ + color: #7c2d12; + }} + .tab-button.is-active {{ + background: linear-gradient(180deg, rgba(180, 83, 9, 0.12), rgba(180, 83, 9, 0.18)); + color: #7c2d12; + box-shadow: inset 0 0 0 1px rgba(180, 83, 9, 0.12); + }} + .report-tab-panel {{ + display: none; + }} + .report-tab-panel.is-active {{ + display: block; + }} + .stats-grid {{ + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + }} .panel-grid {{ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-top: 14px; @@ -1941,23 +3314,300 @@ def render_html(report: dict) -> str: .footer-panel .report-table {{ min-width: 480px; }} - .footer-panel .report-table th, - .footer-panel .report-table td {{ - font-size: 0.70rem; - padding: 5px 7px; - }} - @media (max-width: 900px) {{ - body {{ font-size: 13px; }} - main {{ padding: 16px 12px 40px; }} - .panel-grid {{ grid-template-columns: 1fr; }} - .report-table {{ min-width: 680px; }} - }} - + .footer-panel .report-table th, + .footer-panel .report-table td {{ + font-size: 0.70rem; + padding: 5px 7px; + }} + .decision-reference, + .decision-doc, + .tree-block {{ + overflow: visible; + }} + .decision-reference code, + .formula-box code, + .term-formula, + .formula-node-formula {{ + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + }} + .decision-lead {{ + margin: 0 0 14px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.55; + }} + .decision-doc-grid {{ + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + }} + .decision-doc {{ + border: 1px solid rgba(123, 83, 44, 0.12); + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 253, 248, 0.96), rgba(245, 237, 224, 0.96)); + padding: 14px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); + }} + .decision-doc .panel-head {{ + align-items: flex-start; + margin-bottom: 12px; + }} + .decision-doc .panel-head h3, + .tree-block .panel-head h4 {{ + margin: 0; + }} + .equation-grid {{ + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + }} + .formula-box {{ + margin: 0; + padding: 12px; + border-radius: 14px; + border: 1px solid rgba(180, 83, 9, 0.16); + background: linear-gradient(180deg, rgba(255, 250, 240, 0.98), rgba(252, 242, 227, 0.98)); + color: #6b3f07; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); + overflow: auto; + white-space: pre-wrap; + }} + .decision-note {{ + margin: 10px 0 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.5; + }} + .term-grid, + .rule-grid {{ + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-top: 12px; + }} + .term-card, + .rule-card {{ + background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + }} + .term-formula {{ + margin: 0 0 8px; + font-size: 0.74rem; + line-height: 1.5; + color: #92400e; + overflow-wrap: anywhere; + }} + .term-body {{ + margin: 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.52; + }} + .tree-block {{ + margin-top: 14px; + padding: 12px; + border-radius: 16px; + border: 1px solid rgba(123, 83, 44, 0.12); + background: rgba(255, 253, 248, 0.74); + }} + .formula-tree {{ + overflow: auto; + padding: 96px 6px 6px; + }} + .formula-node-wrap {{ + display: flex; + flex-direction: column; + align-items: center; + min-width: max-content; + position: relative; + }} + .formula-node {{ + position: relative; + display: grid; + gap: 4px; + min-width: 180px; + max-width: 260px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(123, 83, 44, 0.16); + background: linear-gradient(180deg, rgba(255, 253, 248, 0.99), rgba(248, 239, 226, 0.98)); + box-shadow: 0 12px 24px rgba(66, 43, 17, 0.08); + outline: none; + }} + .formula-node:hover, + .formula-node:focus {{ + border-color: rgba(180, 83, 9, 0.72); + box-shadow: 0 16px 28px rgba(180, 83, 9, 0.12); + }} + .formula-node-label {{ + font-weight: 700; + font-size: 0.82rem; + color: var(--ink); + }} + .formula-node-formula {{ + font-size: 0.71rem; + line-height: 1.45; + color: #92400e; + }} + .formula-node-summary {{ + font-size: 0.74rem; + line-height: 1.4; + color: var(--muted); + }} + .formula-tooltip {{ + position: absolute; + left: 50%; + bottom: calc(100% + 12px); + transform: translateX(-50%) translateY(6px); + min-width: 230px; + max-width: 320px; + padding: 10px 12px; + border-radius: 12px; + background: #1f2937; + color: #f8fafc; + font-size: 0.72rem; + line-height: 1.45; + box-shadow: 0 18px 28px rgba(15, 23, 42, 0.28); + opacity: 0; + pointer-events: none; + transition: opacity 140ms ease, transform 140ms ease; + z-index: 25; + }} + .formula-tooltip::after {{ + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 6px; + border-style: solid; + border-color: #1f2937 transparent transparent transparent; + }} + .formula-node:hover .formula-tooltip, + .formula-node:focus .formula-tooltip, + .formula-node:focus-within .formula-tooltip {{ + opacity: 1; + transform: translateX(-50%) translateY(0); + }} + .formula-children {{ + display: flex; + justify-content: center; + gap: 16px; + align-items: flex-start; + position: relative; + padding-top: 18px; + margin-top: 10px; + }} + .formula-children::before {{ + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 18px; + background: var(--line); + }} + .formula-children.has-multiple::after {{ + content: ""; + position: absolute; + top: 0; + left: 12%; + right: 12%; + height: 2px; + background: var(--line); + }} + .formula-branch {{ + position: relative; + display: flex; + flex-direction: column; + align-items: center; + }} + .formula-branch::before {{ + content: ""; + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 18px; + background: var(--line); + }} + .history-index-panel {{ + margin-top: 14px; + }} + .history-stack {{ + display: grid; + gap: 12px; + margin-top: 14px; + }} + .history-card {{ + border: 1px solid rgba(123, 83, 44, 0.12); + border-radius: 18px; + background: rgba(255, 253, 248, 0.92); + overflow: hidden; + box-shadow: 0 14px 24px rgba(66, 43, 17, 0.06); + }} + .history-card summary {{ + list-style: none; + cursor: pointer; + padding: 14px 16px; + }} + .history-card summary::-webkit-details-marker {{ + display: none; + }} + .history-card[open] summary {{ + border-bottom: 1px solid rgba(123, 83, 44, 0.12); + background: linear-gradient(180deg, rgba(245, 237, 224, 0.96), rgba(255, 253, 248, 0.96)); + }} + .history-summary {{ + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + }} + .history-summary h3 {{ + margin: 0 0 6px; + font-size: 0.94rem; + line-height: 1.35; + }} + .history-summary-side {{ + flex-shrink: 0; + }} + .history-body {{ + padding: 14px 16px 16px; + }} + .history-table {{ + min-width: 1120px; + table-layout: auto; + }} + .history-table td {{ + min-width: 120px; + }} + @media (max-width: 900px) {{ + body {{ font-size: 13px; }} + main {{ padding: 16px 12px 40px; }} + .panel-grid {{ grid-template-columns: 1fr; }} + .report-table {{ min-width: 680px; }} + .decision-doc-grid {{ grid-template-columns: 1fr; }} + .formula-tree {{ padding-top: 104px; }} + .tab-strip {{ + width: 100%; + justify-content: stretch; + }} + .tab-button {{ + flex: 1 1 0; + text-align: center; + }} + }} +
    -
    -

    T Cell HTML Report

    +
    +

    T Cell HTML Report

    T Cell Run Report

    Static analysis of observations, signals, transitions, memories, and optional decision traces. Generated at {escape(report['generated_at'])}

    @@ -1967,11 +3617,18 @@ def render_html(report: dict) -> str:

    Database

    {escape(report['sources']['db_path'])}

    Module Log

    {escape(report['sources']['log_path'])}

    Decision Trace

    {escape(report['sources']['trace_path'])}
    -
    -
    +
    + -
    -
    +
    + + +
    + +
    + +
    +

    Quick Summary

    {render_counter_cards(report)} @@ -2030,27 +3687,55 @@ def render_html(report: dict) -> str: {trace_section}
    -
    -

    Recent Observations

    -

    These rows come from the T Cell SQLite DB, so they remain available even when module log verbosity was low. Click a column header to sort.

    - {observation_table} -
    - - - - + + +""" + + +def state_class_name(label: str) -> str: + mapping = {value: STATE_CLASS[key] for key, value in STATE_LABELS.items()} + return mapping.get(label, "state-unknown") + + +def write_report(run_output_dir: Path, output_html: Path, args: argparse.Namespace) -> Path: + report = build_report_payload( + run_output_dir, + max_observations=args.max_observations, + max_log_lines=args.max_log_lines, + max_trace_rows=args.max_trace_rows, + ) + output_html.parent.mkdir(parents=True, exist_ok=True) + output_html.write_text(render_html(report), encoding="utf-8") + return output_html + + +def main() -> int: + args = parse_args() + run_output_dir = Path(args.run_output_dir).expanduser().resolve() + output_html = ( + Path(args.out).expanduser().resolve() + if args.out + else run_output_dir / "t_cell_report.html" + ) + report_path = write_report(run_output_dir, output_html, args) + print(f"Report written to: {report_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 383e1cc5563a35f566dc5e8204eb17a1e4667b36 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:37 +0000 Subject: [PATCH 0949/1100] feat: add offline HTML report generator for T Cell module analysis --- docs/t_cell_module.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index af8e2a444b..1ccf735416 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -418,6 +418,41 @@ Performance note: - trace mode performs extra observation lookups and extra file writes, so it should be treated as a verification feature, not the normal default path +### Offline HTML Report + +The module includes a separate offline report generator: + +```bash +./venv/bin/python modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ +``` + +By default it writes: + +```text +/t_cell_report.html +``` + +The report is static and self-contained. It reads the T Cell SQLite DB as the +primary source, then enriches the page with `t_cell.log` and +`t_cell_trace.jsonl` when those files exist. This means: + +- it still explains the run when `log_verbosity` is `1` +- it gains richer per-evidence detail when `log_verbosity` is `2` or `3` +- it gains threshold-by-threshold explanations when decision tracing is enabled + +The page focuses on the run itself, including: + +- total `PAMP` and `DAMP` observations +- evidence type mix +- extracted antigens and matched regexes +- current cells and their states +- transition reasons and state-path counts +- memories stored so far +- observation, transition, and trace timelines +- a sortable Recent Observations table at the bottom of the page +- a compact, collapsed configuration snapshot at the very end + Color mapping: - `0 - mature` -> cyan From c7293f33743b75be4f46904a63d7793183d47075 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:43 +0000 Subject: [PATCH 0950/1100] feat: add instructions for offline HTML report generation in T Cell module --- modules/t_cell/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index 3473befe02..db0074120d 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -38,6 +38,28 @@ Artifacts: The configured trace path is always forced under the selected run output directory. - module DB: `/t_cell/t_cell.sqlite` +- offline HTML report: `/t_cell_report.html` + +## Local HTML Report + +Use the included offline report generator to build a static HTML page from a +completed or running Slips output directory: + +```bash +./venv/bin/python modules/t_cell/analyze_t_cell.py \ + --run-output-dir output/ +``` + +By default it writes: + +```text +output//t_cell_report.html +``` + +The report reads the T Cell SQLite DB first, then enriches the page with the +module log and decision trace when those files exist. That means it still gives +useful summaries when `log_verbosity` is `1` or `2`, and becomes more detailed +when verbosity `3` or decision tracing is enabled. See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, configuration, formulas, and DB schema. From 29db2c77c214223cd09461213f94c5f4321ccdc7 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 13:59:53 +0000 Subject: [PATCH 0951/1100] feat: add unit tests for T Cell report generation and HTML rendering --- .../modules/t_cell/test_analyze_t_cell.py | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 tests/unit/modules/t_cell/test_analyze_t_cell.py diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py new file mode 100644 index 0000000000..cecabf2fb6 --- /dev/null +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -0,0 +1,330 @@ +# SPDX-FileCopyrightText: 2026 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +import json +from pathlib import Path +from unittest.mock import Mock + +from modules.t_cell.analyze_t_cell import build_report_payload, render_html +from slips_files.core.database.sqlite_db.t_cell_db import TCellStorage + + +def _build_storage(run_dir: Path) -> TCellStorage: + conf = Mock() + conf.t_cell_store_dir = Mock(return_value="output/t_cell") + conf.t_cell_persistent_store_dir = Mock(return_value="") + return TCellStorage(Mock(), conf, str(run_dir), 12345) + + +def _raw_evidence( + evidence_id: str, + evidence_type: str, + signal: str, + related_profile_ip: str, + attacker_ip: str, + victim_ip: str, + description: str, +) -> dict: + return { + "evidence_type": evidence_type, + "description": description, + "attacker": { + "direction": "SRC", + "ioc_type": "IP", + "value": attacker_ip, + }, + "victim": { + "direction": "DST", + "ioc_type": "IP", + "value": victim_ip, + }, + "profile": {"ip": related_profile_ip}, + "timewindow": {"number": 1}, + "uid": [], + "timestamp": "2026/03/21 09:22:37.000000+0000", + "interface": "eno1", + "id": evidence_id, + "confidence": 1.0, + "threat_level": "HIGH", + "evidence_signal": signal, + } + + +def test_build_report_payload_and_html(tmp_path): + run_dir = tmp_path / "run-output" + (run_dir / "metadata").mkdir(parents=True) + storage = _build_storage(run_dir) + + damp_observation_id = storage.insert_observation( + { + "evidence_id": "damp-1", + "evidence_type": "HTTP_TRAFFIC", + "evidence_signal": "DAMP", + "profile_ip": "2001:db8::5", + "timewindow_number": 1, + "timestamp": "2026/03/21 09:22:37.000000+0000", + "observed_at": 1000.0, + "confidence": 0.9, + "threat_level": "medium", + "threat_level_value": 0.5, + "interface": "eno1", + "uids": ["uid-damp-1"], + "antigen_count": 2, + "antigens": [ + {"regex_type": "dns_domain", "value": "rdap.db.ripe.net"}, + {"regex_type": "uri", "value": "/ip/5.161.194.92"}, + ], + "matched_regexes": [], + "raw_evidence": _raw_evidence( + "damp-1", + "HTTP_TRAFFIC", + "DAMP", + "2001:db8::5", + "2001:db8::5", + "2001:67c:2e8:22::c100:697", + "RDAP lookup over HTTP", + ), + } + ) + + pamp_observation_id = storage.insert_observation( + { + "evidence_id": "pamp-1", + "evidence_type": "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "evidence_signal": "PAMP", + "profile_ip": "203.0.113.90", + "timewindow_number": 2, + "timestamp": "2026/03/21 09:23:37.000000+0000", + "observed_at": 2000.0, + "confidence": 1.0, + "threat_level": "high", + "threat_level_value": 0.8, + "interface": "eno1", + "uids": ["uid-pamp-1"], + "antigen_count": 1, + "antigens": [ + {"regex_type": "dns_domain", "value": "bad.example.com"} + ], + "matched_regexes": [ + { + "regex_type": "dns_domain", + "value": "bad.example.com", + "regex_hash": "regex-hash-1", + "regex": r"^bad\.example\.com$", + "created_at": 1990.0, + "specificity": 1.0, + } + ], + "raw_evidence": _raw_evidence( + "pamp-1", + "THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN", + "PAMP", + "147.32.80.37", + "203.0.113.90", + "147.32.80.37", + "Known malicious domain", + ), + } + ) + + cell_key = "203.0.113.90|dns_domain|bad.example.com" + storage.upsert_cell( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "state": 5, + "state_name": "5 - memory", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": pamp_observation_id, + "last_evidence_id": "pamp-1", + "last_transition_at": 2000.3, + "last_co_stimulation": 0.91, + "last_effector_score": 0.33, + "last_memory_score": 0.78, + "context": {"novelty_score": 0, "recent_pressure": 0.42}, + "created_at": 2000.0, + "updated_at": 2000.3, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 0, + "to_state": 1, + "reason": "antigen_match", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"specificity": 1.0}, + "created_at": 2000.1, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 1, + "to_state": 3, + "reason": "co_stimulation_threshold_met", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"value": 0.91, "threshold": 0.65}, + "created_at": 2000.2, + } + ) + storage.insert_transition( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "evidence_id": "pamp-1", + "observation_id": pamp_observation_id, + "from_state": 3, + "to_state": 5, + "reason": "context_memory", + "matched_regex_hash": "regex-hash-1", + "matched_regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "scores": {"memory_score": 0.78, "memory_threshold": 0.60}, + "created_at": 2000.3, + } + ) + storage.upsert_memory( + { + "cell_key": cell_key, + "profile_ip": "203.0.113.90", + "regex_type": "dns_domain", + "antigen_value": "bad.example.com", + "regex_hash": "regex-hash-1", + "regex": r"^bad\.example\.com$", + "matched_value": "bad.example.com", + "context": {"memory_score": 0.78, "recent_pressure": 0.42}, + "created_at": 2000.3, + "updated_at": 2000.3, + } + ) + + (run_dir / "metadata" / "slips.yaml").write_text( + "\n".join( + [ + "t_cell:", + " enabled: true", + " log_verbosity: 3", + " decision_trace_mode: transitions", + " co_stimulation_threshold: 0.65", + " effector_threshold: 0.70", + " memory_threshold: 0.60", + ] + ), + encoding="utf-8", + ) + (run_dir / "t_cell.log").write_text( + "\n".join( + [ + "T Cell module ready.", + "2026/03/21 09:22:37.597262 | action=antigens_extracted | evidence=HTTP_TRAFFIC | eid=damp-1 | signal=DAMP | profile=2001:db8::5 | responsible=2001:db8::5 | target=2001:67c:2e8:22::c100:697 | antigens=dns_domain:rdap.db.ripe.net, uri:/ip/5.161.194.92", + "2026/03/21 09:22:37.607926 | action=ignored_non_pamp | evidence=HTTP_TRAFFIC | eid=damp-1 | signal=DAMP | profile=2001:db8::5 | responsible=2001:db8::5 | target=2001:67c:2e8:22::c100:697", + "2026/03/21 09:23:37.607926 | action=memory_stored | state=5 - memory | evidence=THREAT_INTELLIGENCE_BLACKLISTED_DOMAIN | eid=pamp-1 | signal=PAMP | profile=147.32.80.37 | responsible=203.0.113.90 | target=147.32.80.37 | cell=203.0.113.90|dns_domain|bad.example.com | regex=regex-hash-1 | value=bad.example.com", + ] + ), + encoding="utf-8", + ) + (run_dir / "t_cell_trace.jsonl").write_text( + "\n".join( + [ + json.dumps( + { + "ts": "2026/03/21 09:23:37.200000+0000", + "stage": "co_stimulation", + "action": "co_stimulation_threshold_met", + "from_state": "1 - antigen-recognized", + "to_state": "3 - activated", + "responsible_ip": "203.0.113.90", + "candidate": { + "regex_type": "dns_domain", + "value": "bad.example.com", + }, + "formula": { + "value": 0.91, + "threshold": 0.65, + "components": { + "related_pamps": {"count": 1}, + }, + }, + } + ), + json.dumps( + { + "ts": "2026/03/21 09:23:37.300000+0000", + "stage": "context", + "action": "context_memory", + "from_state": "3 - activated", + "to_state": "5 - memory", + "responsible_ip": "203.0.113.90", + "candidate": { + "regex_type": "dns_domain", + "value": "bad.example.com", + }, + "formula": { + "effector_score": 0.33, + "effector_threshold": 0.70, + "memory_score": 0.78, + "memory_threshold": 0.60, + }, + } + ), + ] + ), + encoding="utf-8", + ) + + payload = build_report_payload(run_dir, max_observations=50, max_log_lines=50, max_trace_rows=50) + + assert payload["totals"]["observations"] == 2 + assert payload["totals"]["signals"] == {"DAMP": 1, "PAMP": 1} + assert payload["totals"]["transitions"] == 3 + assert payload["totals"]["memories"] == 1 + assert payload["cell_states"] == {"5 - memory": 1} + assert payload["sources"]["trace_enabled"] is True + assert payload["trace"]["total_rows"] == 2 + assert payload["recent_observations"][0]["category"] == "PAMP with regex match" + assert any( + row["category"] == "DAMP with extracted antigens" + for row in payload["recent_observations"] + ) + assert payload["top_responsible_ips"][0]["label"] == "2001:db8::5" + + html = render_html(payload) + + assert "T Cell Report" in html + assert "T Cell Run Report" in html + assert "Run Findings" in html + assert "Quick Summary" in html + assert "Decision Trace" in html + assert "Module Log Tail" not in html + assert "data-sortable-table='recent-observations'" in html + assert "Click a column header to sort." in html + assert html.index("Recent Observations") < html.index("Run configuration snapshot") + assert "co_stimulation_threshold_met" in html + assert "context_memory" in html + assert "bad.example.com" in html + assert "DAMP with extracted antigens" in html + assert "PAMP with regex match" in html + + storage.close() From 4186fb8443fe0f206ccd27675ff0eb77f080eb53 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:10 +0000 Subject: [PATCH 0952/1100] feat: add Mermaid state diagram for T Cell state machine and enhance report details --- docs/t_cell_module.md | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 1ccf735416..6800211579 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -93,6 +93,58 @@ The persisted states are: - `4 - effector` - `5 - memory` +Mermaid state diagram: + +```mermaid +stateDiagram-v2 + [*] --> S0 : new cell + + state "0 - mature" as S0 + state "1 - antigen-recognized" as S1 + state "2 - anergic" as S2 + state "3 - activated" as S3 + state "4 - effector" as S4 + state "5 - memory" as S5 + + S0 --> S1 : PAMP + antigen extracted\n+ accepted regex match + S0 --> S2 : PAMP + antigen extracted\n+ no regex match + S0 --> S0 : DAMP only or\nno antigen extracted + + S2 --> S0 : anergy TTL expired + + S1 --> S3 : co-stimulation >= threshold\nwithin 1 Slips TW + S1 --> S1 : re-evaluate on later evidence\nwhile below threshold + S1 --> S2 : co-stimulation timeout\nafter 1 Slips TW + + S3 --> S4 : context says novel + intense + S3 --> S5 : context says familiar + cooling down + S3 --> S3 : re-evaluate on later evidence\nwhile undecided + S3 --> S0 : context timeout\nafter 1 Slips TW + + S5 --> S5 : later matching evidence retained + S4 --> S4 : repeated hits gated by\neffector cooldown + + note right of S0 + DAMP observations are stored as danger signals. + They do not perform antigen recognition + and do not create a new cell by themselves. + end note + + note right of S1 + Co-stimulation combines: + current PAMP confidence + related PAMP count + weighted PAMP+DAMP danger + for the same responsible IP. + end note + + note right of S3 + Context uses the same mixed pressure model + to decide whether to contain now + or store memory for later. + end note +``` + The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. @@ -445,12 +497,14 @@ The page focuses on the run itself, including: - total `PAMP` and `DAMP` observations - evidence type mix +- a rendered T-cell state-machine graph with per-state and per-transition counts - extracted antigens and matched regexes - current cells and their states - transition reasons and state-path counts - memories stored so far - observation, transition, and trace timelines - a sortable Recent Observations table at the bottom of the page +- a sortable Transitions table that defaults to grouping rows by T cell - a compact, collapsed configuration snapshot at the very end Color mapping: From 1bc51ded94a0ff375eb2aa682a5c3e60f282dc8a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:17 +0000 Subject: [PATCH 0953/1100] feat: enhance LLMBackend to support configurable HTTP connection pool size --- modules/llm/llm.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/llm/llm.py b/modules/llm/llm.py index 5f71e2a01a..7982c7404b 100644 --- a/modules/llm/llm.py +++ b/modules/llm/llm.py @@ -114,11 +114,12 @@ def _resolve_api_key(data: dict) -> str | None: class LLMBackend: - def __init__(self, config: LLMBackendConfig): + def __init__(self, config: LLMBackendConfig, pool_maxsize: int = 2): self.config = config self.http = urllib3.PoolManager( cert_reqs="CERT_REQUIRED", ca_certs=certifi.where(), + maxsize=max(2, int(pool_maxsize)), ) def generate(self, request: dict) -> dict: @@ -345,11 +346,14 @@ def read_configuration(self): self.failed_backends[alias] = str(exc) def _create_backend(self, config: LLMBackendConfig) -> LLMBackend: + # Keep the reusable HTTP connection pool comfortably above the + # worker concurrency so busy runs do not spam pool-discard warnings. + pool_maxsize = max(2, self.worker_threads * 2) if config.provider == "openai": - return OpenAIBackend(config) + return OpenAIBackend(config, pool_maxsize=pool_maxsize) if config.provider == "anthropic": - return AnthropicBackend(config) - return OllamaBackend(config) + return AnthropicBackend(config, pool_maxsize=pool_maxsize) + return OllamaBackend(config, pool_maxsize=pool_maxsize) @staticmethod def _empty_available_backends_registry() -> dict: From 57adb19608b78a1e8dc131fce56b03cb96f8893e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:24 +0000 Subject: [PATCH 0954/1100] feat: add sortable transition table and state machine graph to T Cell report --- modules/t_cell/analyze_t_cell.py | 288 ++++++++++++++++++++++++++++--- 1 file changed, 261 insertions(+), 27 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index 2acf246ccc..55fb3c95e6 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -709,12 +709,22 @@ def build_report_payload( "evidence_id": transition["evidence_id"], "from_state": from_label, "to_state": to_label, + "from_state_order": transition.get("from_state", -1), + "to_state_order": transition.get("to_state", -1), "reason": transition["reason"], "matched_value": transition.get("matched_value") or "", "scores": transition.get("scores") or {}, } ) - recent_transitions.sort(key=lambda item: item["ts"], reverse=True) + recent_transitions.sort( + key=lambda item: ( + item["cell_key"].lower(), + float(item["ts"]), + int(item["from_state_order"]), + int(item["to_state_order"]), + item["evidence_id"], + ) + ) current_state_counts = Counter() recent_cells = [] @@ -995,6 +1005,67 @@ def render_sortable_observation_table(rows: list[dict]) -> str: ) +def render_sortable_transition_table(rows: list[dict]) -> str: + if not rows: + return '

    No state transitions were recorded.

    ' + + columns = [ + "When", + "Path", + "Reason", + "Responsible", + "T Cell", + "Evidence", + "Scores", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + score_summary = ", ".join( + f"{key}={value}" for key, value in sorted((row["scores"] or {}).items()) + ) or "n/a" + cells = [ + (escape(row["wall"]), row["ts"]), + ( + f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " + f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}", + f"{row['from_state_order']:02d}->{row['to_state_order']:02d}", + ), + (escape(row["reason"]), row["reason"]), + (escape(row["responsible_ip"]), row["responsible_ip"]), + (escape(shorten(row["cell_key"], 54)), row["cell_key"]), + (escape(shorten(row["evidence_id"], 20)), row["evidence_id"]), + ( + f"
    show
    {render_pretty_json(row['scores'])}
    ", + score_summary, + ), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
    " + "" + f"{head}{body}
    " + ) + + def render_svg_timeline(title: str, timeline: dict, series_order: list[str], color_map: dict[str, str]) -> str: if not timeline: return ( @@ -1062,6 +1133,189 @@ def render_svg_timeline(title: str, timeline: dict, series_order: list[str], col """ +def hex_to_rgba(hex_color: str, alpha: float) -> str: + color = hex_color.lstrip("#") + if len(color) != 6: + return f"rgba(31, 41, 55, {alpha})" + red = int(color[0:2], 16) + green = int(color[2:4], 16) + blue = int(color[4:6], 16) + return f"rgba({red}, {green}, {blue}, {alpha})" + + +def render_state_machine_graph(report: dict) -> str: + node_layout = { + 0: {"x": 40, "y": 122}, + 1: {"x": 320, "y": 44}, + 2: {"x": 320, "y": 244}, + 3: {"x": 600, "y": 122}, + 4: {"x": 880, "y": 30}, + 5: {"x": 880, "y": 214}, + } + node_width = 210 + node_height = 68 + transition_counts = { + row["label"]: row["count"] for row in report.get("transition_paths", []) + } + current_state_counts = report.get("cell_states", {}) + + edges = [ + { + "from": 0, + "to": 1, + "trigger": "regex match", + "path": "M 250 156 C 275 156, 286 120, 320 104", + "label_x": 272, + "label_y": 116, + }, + { + "from": 0, + "to": 2, + "trigger": "no regex", + "path": "M 250 156 C 275 156, 286 286, 320 278", + "label_x": 268, + "label_y": 252, + }, + { + "from": 2, + "to": 0, + "trigger": "anergy TTL", + "path": "M 320 306 C 248 338, 178 322, 146 190", + "label_x": 182, + "label_y": 330, + }, + { + "from": 1, + "to": 1, + "trigger": "wait", + "path": "M 392 44 C 350 4, 502 4, 460 44", + "label_x": 426, + "label_y": 12, + }, + { + "from": 1, + "to": 3, + "trigger": "co-stimulation", + "path": "M 530 78 L 600 156", + "label_x": 542, + "label_y": 94, + }, + { + "from": 1, + "to": 2, + "trigger": "timeout", + "path": "M 425 112 L 425 244", + "label_x": 438, + "label_y": 184, + }, + { + "from": 3, + "to": 3, + "trigger": "wait", + "path": "M 672 122 C 630 82, 782 82, 740 122", + "label_x": 706, + "label_y": 90, + }, + { + "from": 3, + "to": 4, + "trigger": "contain", + "path": "M 810 144 L 880 86", + "label_x": 828, + "label_y": 112, + }, + { + "from": 3, + "to": 5, + "trigger": "remember", + "path": "M 810 168 L 880 248", + "label_x": 824, + "label_y": 214, + }, + { + "from": 3, + "to": 0, + "trigger": "context timeout", + "path": "M 600 156 C 536 236, 286 236, 250 156", + "label_x": 430, + "label_y": 260, + }, + { + "from": 4, + "to": 4, + "trigger": "cooldown", + "path": "M 952 30 C 914 -8, 1088 -8, 1050 30", + "label_x": 1000, + "label_y": 2, + }, + { + "from": 5, + "to": 5, + "trigger": "retained", + "path": "M 952 282 C 914 320, 1088 320, 1050 282", + "label_x": 998, + "label_y": 334, + }, + ] + + node_svg = [] + for state_id, label in STATE_LABELS.items(): + node = node_layout[state_id] + color = STATE_COLORS[state_class(state_id)] + count = current_state_counts.get(label, 0) + node_svg.append( + f""" + + + {escape(label)} + current cells: {count} + + """ + ) + + edge_svg = [] + for edge in edges: + from_label = STATE_LABELS[edge["from"]] + to_label = STATE_LABELS[edge["to"]] + path_key = f"{from_label} -> {to_label}" + count = int(transition_counts.get(path_key, 0)) + active = count > 0 + stroke = STATE_COLORS[state_class(edge["to"])] + edge_svg.append( + f""" + + + + {escape(edge['trigger'])} · {count} + + + """ + ) + + return f""" +
    +
    +

    T Cell State Machine

    +

    Node badges show current cells in each state. Arrow labels show how many times each transition happened in this run.

    +
    + + + + + + + + {''.join(edge_svg)} + {''.join(node_svg)} + +
    + """ + + def render_pretty_json(value: Any) -> str: return escape(json.dumps(value, indent=2, sort_keys=True)) @@ -1116,32 +1370,8 @@ def render_html(report: dict) -> str: report["recent_observations"] ) - transition_table = render_simple_table( - [ - "When", - "Path", - "Reason", - "Responsible", - "Cell", - "Evidence", - "Scores", - ], - [ - { - "When": escape(row["wall"]), - "Path": ( - f"{render_badge(row['from_state'], state_class_name(row['from_state']))} " - f"→ {render_badge(row['to_state'], state_class_name(row['to_state']))}" - ), - "Reason": escape(row["reason"]), - "Responsible": escape(row["responsible_ip"]), - "Cell": escape(shorten(row["cell_key"], 54)), - "Evidence": escape(shorten(row["evidence_id"], 20)), - "Scores": f"
    show
    {render_pretty_json(row['scores'])}
    ", - } - for row in report["recent_transitions"] - ], - "No state transitions were recorded.", + transition_table = render_sortable_transition_table( + report["recent_transitions"] ) cell_table = render_simple_table( @@ -1332,6 +1562,7 @@ def render_html(report: dict) -> str: ["co-stimulation", "context"], TRACE_STAGE_COLORS, ) + state_machine_graph = render_state_machine_graph(report) return f""" @@ -1668,6 +1899,8 @@ def render_html(report: dict) -> str: {trace_timeline}
    + {state_machine_graph} +

    Signals

    @@ -1685,6 +1918,7 @@ def render_html(report: dict) -> str:

    Transitions

    +

    Click a column header to sort. Default order groups rows by T cell so each cell's path stays together.

    {transition_table}
    From 39823849b03a0f8f08e535ce6cf3e3ee9d2b22d1 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:30 +0000 Subject: [PATCH 0955/1100] feat: add state machine diagram for T Cell module in README --- modules/t_cell/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index db0074120d..c8bcb53322 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -31,6 +31,31 @@ Main behavior: - containment reuses the existing `new_blocking` payload shape - all T Cell state is stored in its own SQLite DB and log file +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> S0 + + state "0 - mature" as S0 + state "1 - antigen-recognized" as S1 + state "2 - anergic" as S2 + state "3 - activated" as S3 + state "4 - effector" as S4 + state "5 - memory" as S5 + + S0 --> S1 : PAMP + antigen + regex match + S0 --> S2 : PAMP + antigen + no regex match + S0 --> S0 : DAMP only or no antigen + S2 --> S0 : anergy TTL expired + S1 --> S3 : co-stimulation threshold met + S1 --> S2 : co-stimulation timeout + S3 --> S4 : context -> contain + S3 --> S5 : context -> remember + S3 --> S0 : context timeout + S5 --> S5 : later matching evidence retained +``` + Artifacts: - module log: `output/t_cell.log` From 85cac0c6e0ece1e6f09696755f0ecbbb59802990 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:35 +0000 Subject: [PATCH 0956/1100] feat: add test for LLMBackend pool size scaling with worker threads --- tests/unit/modules/llm/test_llm.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/modules/llm/test_llm.py b/tests/unit/modules/llm/test_llm.py index 6fff9eb2bb..0391b05666 100644 --- a/tests/unit/modules/llm/test_llm.py +++ b/tests/unit/modules/llm/test_llm.py @@ -277,3 +277,21 @@ def test_ollama_backend_parses_response(): assert response["usage"]["input_tokens"] == 9 assert response["usage"]["output_tokens"] == 11 assert response["usage"]["total_tokens"] == 20 + + +def test_llm_backend_pool_size_scales_with_worker_threads(): + llm = ModuleFactory().create_llm_obj() + llm.worker_threads = 3 + config = LLMBackendConfig.from_dict( + "local_qwen", + { + "provider": "ollama", + "model": "qwen2.5:3b", + "base_url": "http://127.0.0.1:11434", + }, + ) + + with patch("modules.llm.llm.urllib3.PoolManager") as mock_pool: + llm._create_backend(config) + + assert mock_pool.call_args.kwargs["maxsize"] == 6 From 5d9ae81987cbba63a37325207402a616868a9d98 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sat, 21 Mar 2026 14:24:43 +0000 Subject: [PATCH 0957/1100] feat: enhance report HTML output with T Cell state machine details and sortable transitions --- tests/unit/modules/t_cell/test_analyze_t_cell.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index cecabf2fb6..454a5fb29a 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -317,8 +317,14 @@ def test_build_report_payload_and_html(tmp_path): assert "Run Findings" in html assert "Quick Summary" in html assert "Decision Trace" in html + assert "T Cell State Machine" in html + assert "regex match" in html + assert "current cells: 1" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html + assert "data-sortable-table='recent-transitions'" in html + assert "data-default-sort-column='4'" in html + assert "Default order groups rows by T cell" in html assert "Click a column header to sort." in html assert html.index("Recent Observations") < html.index("Run configuration snapshot") assert "co_stimulation_threshold_met" in html From cd700d2e1b48da9cd28dde242d3abc4f6da32a75 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:09 +0000 Subject: [PATCH 0958/1100] fix: clarify DAMP evidence handling in T Cell module description --- docs/evidence_signals.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/evidence_signals.md b/docs/evidence_signals.md index 544898b8a0..12b9cbbca8 100644 --- a/docs/evidence_signals.md +++ b/docs/evidence_signals.md @@ -6,7 +6,9 @@ The `T Cell` module consumes this same central field and only activates its state machine for antigen recognition from `PAMP` evidence. `DAMP` evidence is still stored by the module as an observation and contributes to the danger pressure used in T-cell co-stimulation and context calculations for the same -responsible IP, but it does not create cells or perform regex matching. See +responsible IP, and each new `DAMP` also reevaluates cells that are already +waiting on that responsible IP. `DAMP` does not create cells or perform regex +matching by itself. See [T Cell Module](t_cell_module.md) for the responder details. The supported values are: From 2cab44fdd993db5ce9347f8ac6d5875ad3ab7ff5 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:15 +0000 Subject: [PATCH 0959/1100] feat: add link to T Cell offline report generation in README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a14216beec..218d6addf5 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,9 @@ We appreciate your contributions and thank you for helping to improve Slips! T Cell design and configuration: [docs/t_cell_module.md](docs/t_cell_module.md) +T Cell offline report generation and interpretation: +[docs/t_cell_module.md#offline-html-report](docs/t_cell_module.md#offline-html-report) + [Code docs](https://stratospherelinuxips.readthedocs.io/en/develop/code_documentation.html ) --- From 7c937ae029decee10614f411e4f1c9fb1b33356a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:28 +0000 Subject: [PATCH 0960/1100] feat: enhance T Cell module documentation with DAMP handling and waiting states --- docs/t_cell_module.md | 88 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/docs/t_cell_module.md b/docs/t_cell_module.md index 6800211579..ab714f10be 100644 --- a/docs/t_cell_module.md +++ b/docs/t_cell_module.md @@ -6,7 +6,8 @@ accepted RegexGenerator regex corpus, and then escalates through a small state machine until it either becomes tolerant, publishes a containment request, or stores a memory snapshot for later reuse. `DAMP` observations do not perform antigen recognition, but they do raise the danger pressure used later in -co-stimulation and context decisions. +co-stimulation and context decisions and they now trigger reevaluation of +already waiting cells for the same responsible IP. The module is started by the normal Slips module loader and is enabled by default through `t_cell.enabled: true`. @@ -23,8 +24,8 @@ modules: 4. It matches those values against accepted regexes already stored by `RegexGenerator`. 5. It stores `DAMP` observations as responsible-IP danger signals and folds - them into co-stimulation and context pressure for later `PAMP` - reevaluations. + them into co-stimulation and context pressure, and `DAMP` arrivals also + trigger reevaluation of cells that are already waiting. 6. It computes co-stimulation and context scores. 7. It either becomes tolerant, activates, requests blocking, or stores memory. @@ -93,6 +94,16 @@ The persisted states are: - `4 - effector` - `5 - memory` +States `1 - antigen-recognized` and `3 - activated` can also carry an +explicit waiting substatus in the stored cell context: + +- `1 - antigen-recognized (waiting for co-stimulation)` +- `3 - activated (waiting for context)` + +This does not create new state numbers. It is an explicit runtime marker that +the cell is still in state `1` or `3`, but is currently waiting for the next +reevaluation. + Mermaid state diagram: ```mermaid @@ -113,12 +124,12 @@ stateDiagram-v2 S2 --> S0 : anergy TTL expired S1 --> S3 : co-stimulation >= threshold\nwithin 1 Slips TW - S1 --> S1 : re-evaluate on later evidence\nwhile below threshold + S1 --> S1 : re-evaluate on later PAMP or DAMP\nwhile below threshold S1 --> S2 : co-stimulation timeout\nafter 1 Slips TW S3 --> S4 : context says novel + intense S3 --> S5 : context says familiar + cooling down - S3 --> S3 : re-evaluate on later evidence\nwhile undecided + S3 --> S3 : re-evaluate on later PAMP or DAMP\nwhile undecided S3 --> S0 : context timeout\nafter 1 Slips TW S5 --> S5 : later matching evidence retained @@ -127,7 +138,8 @@ stateDiagram-v2 note right of S0 DAMP observations are stored as danger signals. They do not perform antigen recognition - and do not create a new cell by themselves. + and do not create a new cell by themselves, + but they do re-check waiting cells. end note note right of S1 @@ -149,11 +161,13 @@ The runtime flow is: 1. Slips publishes an evidence on `evidence_added`. 2. The module stores one observation row in its own SQLite DB. -3. If the evidence signal is not `PAMP`, the module logs `ignored_non_pamp` - and stops for that evidence after storing the observation. -4. Stored `DAMP` observations do not create or match cells, but they are kept - as danger inputs and are included in the next co-stimulation or context - evaluation for the same responsible IP. +3. If the evidence signal is `DAMP`, the module stores the observation, + reevaluates any waiting cells for the same responsible IP, logs + `damp_reverification`, and does not attempt antigen recognition from that + evidence. +4. If the evidence signal is neither `PAMP` nor `DAMP`, the module logs + `ignored_non_pamp` and stops for that evidence after storing the + observation. 5. If no structured antigen can be extracted, the module logs `no_antigen_extracted` and stops for that evidence. 6. For each antigen candidate, the module loads or creates the cell in @@ -166,11 +180,13 @@ The runtime flow is: a new `anergic_until`. 10. If a regex matches, the cell goes `0 -> 1` and stores the chosen regex metadata. -11. The module computes co-stimulation from the current `PAMP`, related - `PAMP`s, and stored `DAMP` danger pressure for the same responsible IP. +11. The module computes co-stimulation from the recognized `PAMP` + confidence, related `PAMP`s, and stored `DAMP` danger pressure for the + same responsible IP. 12. If co-stimulation crosses the configured threshold, the cell goes `1 -> 3`. 13. If co-stimulation stays below threshold, the cell can wait in - `1 - antigen-recognized` for at most one configured Slips time window. + `1 - antigen-recognized` for at most one configured Slips time window, + with the cell explicitly marked as waiting for co-stimulation. 14. If that one-time-window wait expires without enough co-stimulation, the cell goes `1 -> 2 - anergic`. 15. In state `3`, the module computes context signals from the same mixed @@ -182,6 +198,11 @@ The runtime flow is: 18. If state `3` cannot decide effector or memory within one configured Slips time window, the cell goes `3 -> 0 - mature`. +Both waiting states are reevaluated on later matching `PAMP`s and on later +`DAMP` observations for the same responsible IP. `DAMP` still does not create +or match a new cell by itself; it only re-checks cells that already exist and +are waiting. + State `4` publishes the existing `new_blocking` payload for the responsible IP when blocking support is present. If blocking or ARP poisoning modules are not running, the module can simulate the effector decision and log the exact @@ -485,6 +506,9 @@ By default it writes: /t_cell_report.html ``` +You can then open that HTML file directly in any browser. If you want a +different output filename, pass `--out `. + The report is static and self-contained. It reads the T Cell SQLite DB as the primary source, then enriches the page with `t_cell.log` and `t_cell_trace.jsonl` when those files exist. This means: @@ -493,6 +517,10 @@ primary source, then enriches the page with `t_cell.log` and - it gains richer per-evidence detail when `log_verbosity` is `2` or `3` - it gains threshold-by-threshold explanations when decision tracing is enabled +Example report screenshot from a real run: + +![T Cell HTML report overview](images/t_cell/t_cell_report_overview.png) + The page focuses on the run itself, including: - total `PAMP` and `DAMP` observations @@ -507,6 +535,38 @@ The page focuses on the run itself, including: - a sortable Transitions table that defaults to grouping rows by T cell - a compact, collapsed configuration snapshot at the very end +How to read the report: + +- **Quick Summary** and **Run Findings** tell you first whether the module saw + mostly `PAMP` or `DAMP`, whether cells were created at all, and whether the + run stalled because no supported antigen could be extracted. +- **Observation / Transition timelines** show when pressure and state changes + happened over time. This is the fastest way to see whether the module was + mostly idle, mostly collecting danger, or actively moving cells. +- **T Cell State Machine** overlays the abstract state machine with run data: + each node shows how many cells are currently in that state, and each arrow + shows how many times that transition happened in the run. +- **Signals**, **Evidence Types**, and the top-* panels show what fed the + danger model: which evidence classes dominated, which responsible IPs or + targets were involved most often, and which antigens or unmatched `PAMP` + values kept appearing. +- **Transitions** is the per-cell transition history. It is sortable and + defaults to grouping rows by T cell, so you can read one cell's path from + `0 - mature` onward without manually regrouping the table. +- **Current Cells** shows the cells that still exist now, their current state, + any explicit waiting substatus such as `waiting for co-stimulation` or + `waiting for context`, and the latest co-stimulation / effector / memory + scores that were stored on the cell. +- **Stored Memories** shows which cells have already reached + `5 - memory`, along with the saved context snapshot that will be reused + later. +- **Decision Trace** is the threshold-audit section. When enabled, it is where + you verify why a threshold passed by checking the weighted formula terms and + contributing evidence IDs. +- **Recent Observations** stays at the bottom as the raw sortable evidence + audit table. It is the best section to correlate what Slips generated with + what T Cell actually received and stored. + Color mapping: - `0 - mature` -> cyan From 612585ccf44e0641f3e511f79ee4a6ce7c9ed0c6 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:38 +0000 Subject: [PATCH 0961/1100] feat: add waiting state handling and sortable cell table to T Cell report --- modules/t_cell/analyze_t_cell.py | 166 +++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 41 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index 55fb3c95e6..86bf52cd86 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -58,6 +58,10 @@ } SIGNAL_COLORS = {"PAMP": "#c2410c", "DAMP": "#0369a1"} TRACE_STAGE_COLORS = {"co_stimulation": "#b45309", "context": "#7c3aed"} +WAITING_LABELS = { + "co_stimulation": "waiting for co-stimulation", + "context": "waiting for context", +} def parse_args() -> argparse.Namespace: @@ -155,6 +159,19 @@ def state_class(state: int | None) -> str: return STATE_CLASS.get(state, "state-unknown") +def cell_waiting_label(cell: dict | None) -> str: + context = (cell or {}).get("context") or {} + return WAITING_LABELS.get(context.get("waiting_for"), "") + + +def display_cell_state(cell: dict) -> str: + label = state_label(cell.get("state")) + waiting_label = cell_waiting_label(cell) + if waiting_label: + return f"{label} ({waiting_label})" + return label + + def shorten(value: Any, limit: int = 96) -> str: text = str(value or "") if len(text) <= limit: @@ -738,6 +755,7 @@ def build_report_payload( "cell_key": cell["cell_key"], "responsible_ip": cell["responsible_ip"], "state": label, + "state_display": display_cell_state(cell), "state_class": state_class(cell["state"]), "regex_type": cell["regex_type"], "antigen_value": cell["antigen_value"], @@ -746,6 +764,7 @@ def build_report_payload( "last_effector_score": cell.get("last_effector_score"), "last_memory_score": cell.get("last_memory_score"), "last_evidence_id": cell.get("last_evidence_id") or "", + "waiting_label": cell_waiting_label(cell), } ) recent_cells.sort(key=lambda item: item["ts"], reverse=True) @@ -947,6 +966,90 @@ def render_simple_table(columns: list[str], rows: list[dict], empty_text: str) - ) +def render_sortable_cell_table(rows: list[dict]) -> str: + if not rows: + return '

    No cells are stored.

    ' + + columns = [ + "Updated", + "State", + "Responsible", + "T Cell", + "Antigen", + "Matched value", + "Scores", + ] + head = "".join( + ( + "" + f"" + "" + ) + for index, column in enumerate(columns) + ) + + body_rows = [] + for index, row in enumerate(rows): + score_parts = [ + f"co={format_float(row['last_co_stimulation'])}" + if row["last_co_stimulation"] is not None + else "", + f"eff={format_float(row['last_effector_score'])}" + if row["last_effector_score"] is not None + else "", + f"mem={format_float(row['last_memory_score'])}" + if row["last_memory_score"] is not None + else "", + ] + score_summary = ", ".join(part for part in score_parts if part) or "n/a" + waiting_html = "" + if row["waiting_label"]: + waiting_html = ( + f"
    {escape(row['waiting_label'])}
    " + ) + cells = [ + (escape(row["wall"]), row["ts"]), + ( + "
    " + f"{render_badge(row['state'], row['state_class'])}" + f"{waiting_html}" + "
    ", + row["state"], + ), + (escape(row["responsible_ip"]), row["responsible_ip"]), + ( + f"
    {escape(shorten(row['cell_key'], 72))}
    ", + row["cell_key"], + ), + ( + f"
    {escape(row['regex_type'])}:" + f"{escape(shorten(row['antigen_value'], 52))}
    ", + f"{row['regex_type']}:{row['antigen_value']}", + ), + ( + f"
    {escape(shorten(row['matched_value'], 52))}
    ", + row["matched_value"], + ), + (escape(score_summary), score_summary), + ] + body_cells = "".join( + f"{html_value}" + for html_value, sort_value in cells + ) + body_rows.append(f"{body_cells}") + + body = "".join(body_rows) + return ( + "
    " + "" + f"{head}{body}
    " + ) + + def render_sortable_observation_table(rows: list[dict]) -> str: if not rows: return '

    No observations available.

    ' @@ -1374,47 +1477,7 @@ def render_html(report: dict) -> str: report["recent_transitions"] ) - cell_table = render_simple_table( - [ - "Updated", - "State", - "Responsible", - "Cell", - "Antigen", - "Matched value", - "Scores", - ], - [ - { - "Updated": escape(row["wall"]), - "State": render_badge(row["state"], row["state_class"]), - "Responsible": escape(row["responsible_ip"]), - "Cell": escape(shorten(row["cell_key"], 56)), - "Antigen": escape(f"{row['regex_type']}:{shorten(row['antigen_value'], 40)}"), - "Matched value": escape(shorten(row["matched_value"], 48)), - "Scores": escape( - ", ".join( - part - for part in [ - f"co={format_float(row['last_co_stimulation'])}" - if row["last_co_stimulation"] is not None - else "", - f"eff={format_float(row['last_effector_score'])}" - if row["last_effector_score"] is not None - else "", - f"mem={format_float(row['last_memory_score'])}" - if row["last_memory_score"] is not None - else "", - ] - if part - ) - or "n/a" - ), - } - for row in report["recent_cells"] - ], - "No cells are stored.", - ) + cell_table = render_sortable_cell_table(report["recent_cells"]) memory_table = render_simple_table( ["Updated", "Responsible", "Cell", "Regex", "Matched value", "Context"], @@ -1755,6 +1818,26 @@ def render_html(report: dict) -> str: .report-table tr:last-child td {{ border-bottom: none; }} + .cells-table {{ + min-width: 900px; + table-layout: auto; + }} + .cell-state-stack {{ + display: grid; + gap: 4px; + min-width: 0; + align-items: start; + }} + .cell-substate {{ + color: var(--muted); + font-size: 0.68rem; + line-height: 1.2; + }} + .cell-key {{ + line-height: 1.25; + overflow-wrap: anywhere; + word-break: break-word; + }} .sort-button {{ display: inline-flex; align-items: center; @@ -1925,6 +2008,7 @@ def render_html(report: dict) -> str:

    Current Cells

    +

    Click a column header to sort. Waiting cells keep the main state badge and show the wait condition underneath.

    {cell_table}
    From f04fb4b71212f2dd0600d5b7e7e2b3d0a33321d6 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:45 +0000 Subject: [PATCH 0962/1100] feat: enhance README with detailed T Cell behavior and report insights --- modules/t_cell/README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/modules/t_cell/README.md b/modules/t_cell/README.md index c8bcb53322..144c9a21a4 100644 --- a/modules/t_cell/README.md +++ b/modules/t_cell/README.md @@ -18,14 +18,16 @@ Main behavior: - `evidence.profile.ip` is the related host context, while containment and T-cell ownership use the evidence's responsible IP - stored `DAMP` observations raise the danger pressure used by - co-stimulation and context for the same responsible IP + co-stimulation and context for the same responsible IP, and each new DAMP + reevaluates waiting cells on that responsible IP - optional decision tracing writes a separate JSONL audit file showing which evidence IDs contributed to threshold calculations - co-stimulation and context scores decide whether the cell becomes tolerant, activates, requests containment, or stores memory - state `1 - antigen-recognized` and state `3 - activated` can each wait for at most one configured Slips time window before timing out to `2 - anergic` - or `0 - mature` + or `0 - mature`; waiting cells are explicitly marked as + `waiting for co-stimulation` or `waiting for context` - once a cell reaches `5 - memory`, later matching evidence keeps it in memory without emitting repeated `memory_stored` actions - containment reuses the existing `new_blocking` payload shape @@ -49,9 +51,11 @@ stateDiagram-v2 S0 --> S0 : DAMP only or no antigen S2 --> S0 : anergy TTL expired S1 --> S3 : co-stimulation threshold met + S1 --> S1 : later PAMP or DAMP re-check S1 --> S2 : co-stimulation timeout S3 --> S4 : context -> contain S3 --> S5 : context -> remember + S3 --> S3 : later PAMP or DAMP re-check S3 --> S0 : context timeout S5 --> S5 : later matching evidence retained ``` @@ -81,10 +85,23 @@ By default it writes: output//t_cell_report.html ``` +Open that HTML file locally in a browser. If you want a different filename, +pass `--out `. + The report reads the T Cell SQLite DB first, then enriches the page with the module log and decision trace when those files exist. That means it still gives useful summaries when `log_verbosity` is `1` or `2`, and becomes more detailed when verbosity `3` or decision tracing is enabled. +What the report tells you: + +- whether the run was dominated by `PAMP`, `DAMP`, or both +- which evidence types, responsible IPs, targets, and antigens drove the run +- which T-cell state transitions happened and how many times +- which cells are currently waiting, activated, anergic, effector, or memory +- why thresholds were crossed when decision tracing was enabled +- which raw observations reached the T Cell module, even when log verbosity was + low + See [docs/t_cell_module.md](../../docs/t_cell_module.md) for the full design, configuration, formulas, and DB schema. From 12b517534f23b687bace494cc7b19132ca6d9181 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:51 +0000 Subject: [PATCH 0963/1100] feat: implement waiting state handling and DAMP reevaluation in T Cell module --- modules/t_cell/t_cell.py | 560 ++++++++++++++++++++++++++++++--------- 1 file changed, 441 insertions(+), 119 deletions(-) diff --git a/modules/t_cell/t_cell.py b/modules/t_cell/t_cell.py index ded0c8fc56..7b9f420a09 100644 --- a/modules/t_cell/t_cell.py +++ b/modules/t_cell/t_cell.py @@ -57,6 +57,13 @@ TRACE_MODE_OFF = 0 TRACE_MODE_TRANSITIONS = 1 TRACE_MODE_ALL = 2 +CONTEXT_REMOVE = object() +WAITING_CO_STIMULATION = "co_stimulation" +WAITING_CONTEXT = "context" +WAITING_LABELS = { + WAITING_CO_STIMULATION: "waiting for co-stimulation", + WAITING_CONTEXT: "waiting for context", +} @dataclass(frozen=True) @@ -264,6 +271,27 @@ def _process_evidence_message(self, message: dict): ) matched_regexes = [] + if evidence.evidence_signal == EvidenceSignal.DAMP: + reevaluated_count = self._reevaluate_waiting_cells( + evidence=evidence, + observation_id=observation_id, + responsible_ip=responsible_ip, + now=now, + ) + self._log_event( + action="damp_reverification", + state=None, + evidence=evidence, + metrics={"reevaluated_cells": reevaluated_count}, + details=( + "stored DAMP danger and rechecked waiting cells for this " + "responsible IP" + ), + verbosity=LOG_VERBOSITY_DECISIONS, + ) + self._prune_observations(now) + return + if evidence.evidence_signal != EvidenceSignal.PAMP: self._log_event( action="ignored_non_pamp", @@ -364,10 +392,12 @@ def _process_candidate( now, last_observation_id=observation_id, last_evidence_id=evidence.id, - context={ - "reason": "no_regex_match_after_activation", - "observation_id": observation_id, - }, + ) + self._update_cell_context( + cell, + now, + reason="no_regex_match_after_activation", + observation_id=observation_id, ) self._log_event( action="no_regex_match", @@ -408,16 +438,348 @@ def _process_candidate( now, **match_updates, ) + cell = self._remember_match_context( + cell, + now, + observation_id, + evidence.id, + match, + ) if cell["state"] == STATE_MEMORY: - self._update_cell( + self._update_cell_context( cell, now, - context={ - "reason": "memory_retained", - "observation_id": observation_id, + reason="memory_retained", + observation_id=observation_id, + matched_regex_hash=match.regex_hash, + ) + self._log_event( + action="memory_retained", + state=STATE_MEMORY, + evidence=evidence, + cell=cell, + match=match, + details=( + "memory already exists for this cell; keeping the memory " + "state without storing a new memory event" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + return match + + return self._advance_cell_with_match( + cell=cell, + evidence=evidence, + observation_id=observation_id, + candidate=candidate, + match=match, + now=now, + responsible_ip=responsible_ip, + reference_observation_id=observation_id, + ) + + def _get_or_create_cell( + self, profile_ip: str, regex_type: str, antigen_value: str, now: float + ) -> dict: + cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) + cell = self.storage.get_cell(cell_key) + if cell: + return cell + + return { + "cell_key": cell_key, + "profile_ip": profile_ip, + "regex_type": regex_type, + "antigen_value": antigen_value, + "state": STATE_MATURE, + "state_name": STATE_INFO[STATE_MATURE]["label"], + "matched_regex_hash": None, + "matched_regex": None, + "matched_value": None, + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": None, + "last_evidence_id": None, + "last_transition_at": None, + "last_co_stimulation": None, + "last_effector_score": None, + "last_memory_score": None, + "context": {}, + "created_at": now, + "updated_at": now, + } + + def _transition_cell( + self, + cell: dict, + to_state: int, + reason: str, + evidence, + observation_id: int, + now: float, + match: RegexMatch | None = None, + scores: dict | None = None, + extra_updates: dict | None = None, + ) -> dict: + from_state = cell["state"] + updates = { + "state": to_state, + "state_name": STATE_INFO[to_state]["label"], + "last_observation_id": observation_id, + "last_evidence_id": evidence.id, + "last_transition_at": now, + } + if match: + updates.update( + { "matched_regex_hash": match.regex_hash, - }, + "matched_regex": match.regex, + "matched_value": match.value, + } + ) + if extra_updates: + updates.update(extra_updates) + + cell = self._update_cell(cell, now, **updates) + self.storage.insert_transition( + { + "cell_key": cell["cell_key"], + "profile_ip": cell["profile_ip"], + "regex_type": cell["regex_type"], + "antigen_value": cell["antigen_value"], + "evidence_id": evidence.id, + "observation_id": observation_id, + "from_state": from_state, + "to_state": to_state, + "reason": reason, + "matched_regex_hash": cell.get("matched_regex_hash"), + "matched_regex": cell.get("matched_regex"), + "matched_value": cell.get("matched_value"), + "scores": scores or {}, + "created_at": now, + } + ) + self._log_event( + action=reason, + state=to_state, + evidence=evidence, + cell=cell, + match=match, + metrics=scores, + verbosity=LOG_VERBOSITY_SUMMARY, + ) + return cell + + def _update_cell(self, cell: dict, now: float, **updates) -> dict: + cell.update(updates) + cell["updated_at"] = now + self.storage.upsert_cell(cell) + return cell + + @staticmethod + def _merge_cell_context_values(cell: dict, **updates) -> dict: + merged = dict(cell.get("context") or {}) + for key, value in updates.items(): + if value is CONTEXT_REMOVE: + merged.pop(key, None) + continue + merged[key] = value + return merged + + def _update_cell_context(self, cell: dict, now: float, **updates) -> dict: + return self._update_cell( + cell, + now, + context=self._merge_cell_context_values(cell, **updates), + ) + + def _remember_match_context( + self, + cell: dict, + now: float, + observation_id: int, + evidence_id: str, + match: RegexMatch, + ) -> dict: + return self._update_cell_context( + cell, + now, + recognition_observation_id=observation_id, + recognition_evidence_id=evidence_id, + matched_regex_created_at=match.created_at, + matched_regex_specificity=match.specificity, + ) + + def _clear_waiting_context(self, cell: dict, now: float) -> dict: + return self._update_cell_context( + cell, + now, + waiting_for=CONTEXT_REMOVE, + waiting_label=CONTEXT_REMOVE, + waiting_since=CONTEXT_REMOVE, + wait_deadline=CONTEXT_REMOVE, + wait_trigger_signal=CONTEXT_REMOVE, + wait_trigger_evidence_id=CONTEXT_REMOVE, + wait_trigger_observation_id=CONTEXT_REMOVE, + ) + + def _set_waiting_context( + self, + cell: dict, + now: float, + waiting_for: str, + evidence, + observation_id: int, + ) -> dict: + context = cell.get("context") or {} + waiting_since = context.get("waiting_since") + if context.get("waiting_for") != waiting_for or waiting_since is None: + waiting_since = ( + cell.get("last_transition_at") + or cell.get("created_at") + or now + ) + try: + waiting_since = float(waiting_since) + except (TypeError, ValueError): + waiting_since = float(now) + return self._update_cell_context( + cell, + now, + waiting_for=waiting_for, + waiting_label=WAITING_LABELS.get(waiting_for, waiting_for), + waiting_since=waiting_since, + wait_deadline=waiting_since + self.state_wait_timeout_seconds, + wait_trigger_signal=str(evidence.evidence_signal), + wait_trigger_evidence_id=evidence.id, + wait_trigger_observation_id=observation_id, + ) + + def _get_reference_observation_id( + self, cell: dict, fallback_observation_id: int + ) -> int: + context = cell.get("context") or {} + candidate_id = ( + context.get("recognition_observation_id") + or cell.get("last_observation_id") + or fallback_observation_id + ) + try: + return int(candidate_id) + except (TypeError, ValueError): + return int(fallback_observation_id) + + def _build_match_from_cell(self, cell: dict) -> RegexMatch | None: + regex_hash = str(cell.get("matched_regex_hash") or "").strip() + regex = str(cell.get("matched_regex") or "").strip() + regex_type = str(cell.get("regex_type") or "").strip() + value = str( + cell.get("matched_value") or cell.get("antigen_value") or "" + ).strip() + if not (regex_hash and regex and regex_type and value): + return None + + context = cell.get("context") or {} + created_at = context.get("matched_regex_created_at") or 0.0 + try: + created_at = float(created_at) + except (TypeError, ValueError): + created_at = 0.0 + + specificity = context.get("matched_regex_specificity") + try: + specificity = float(specificity) + except (TypeError, ValueError): + specificity = measure_regex_specificity(regex) + + return RegexMatch( + regex_type=regex_type, + value=value, + regex_hash=regex_hash, + regex=regex, + created_at=created_at, + specificity=specificity, + ) + + def _reevaluate_waiting_cells( + self, + evidence, + observation_id: int, + responsible_ip: str, + now: float, + ) -> int: + waiting_cells = self.storage.get_cells_for_profile_states( + responsible_ip, + [STATE_ANTIGEN_RECOGNIZED, STATE_ACTIVATED], + ) + reevaluated = 0 + for cell in waiting_cells: + match = self._build_match_from_cell(cell) + if not match: + self._log_event( + action="waiting_cell_missing_match", + state=cell["state"], + evidence=evidence, + cell=cell, + details=( + "cannot reevaluate waiting cell because the stored " + "regex match metadata is incomplete" + ), + verbosity=LOG_VERBOSITY_DEBUG, + ) + continue + + candidate = AntigenCandidate( + regex_type=cell["regex_type"], + value=cell["antigen_value"], + ) + reference_observation_id = self._get_reference_observation_id( + cell, + observation_id, + ) + self._advance_cell_with_match( + cell=cell, + evidence=evidence, + observation_id=observation_id, + candidate=candidate, + match=match, + now=now, + responsible_ip=responsible_ip, + reference_observation_id=reference_observation_id, + ) + reevaluated += 1 + return reevaluated + + def _advance_cell_with_match( + self, + cell: dict, + evidence, + observation_id: int, + candidate: AntigenCandidate, + match: RegexMatch, + now: float, + responsible_ip: str, + reference_observation_id: int, + ) -> RegexMatch: + if ( + cell.get("last_observation_id") != observation_id + or cell.get("last_evidence_id") != evidence.id + ): + cell = self._update_cell( + cell, + now, + last_observation_id=observation_id, + last_evidence_id=evidence.id, + ) + + if cell["state"] == STATE_MEMORY: + cell = self._update_cell_context( + cell, + now, + reason="memory_retained", + observation_id=observation_id, + matched_regex_hash=match.regex_hash, ) self._log_event( action="memory_retained", @@ -435,7 +797,7 @@ def _process_candidate( co_stimulation = self._compute_co_stimulation( responsible_ip, - observation_id, + reference_observation_id, candidate, match, now, @@ -444,7 +806,11 @@ def _process_candidate( cell, now, last_co_stimulation=co_stimulation["value"], - context={"co_stimulation": co_stimulation}, + ) + cell = self._update_cell_context( + cell, + now, + co_stimulation=co_stimulation, ) if cell["state"] < STATE_ACTIVATED: @@ -473,6 +839,7 @@ def _process_candidate( match=match, scores=co_stimulation, ) + cell = self._clear_waiting_context(cell, now) elif ( cell["state"] == STATE_ANTIGEN_RECOGNIZED and self._state_wait_expired(cell, now) @@ -513,8 +880,16 @@ def _process_candidate( "anergic_until": now + self.anergy_ttl_seconds, }, ) + cell = self._clear_waiting_context(cell, now) return match else: + cell = self._set_waiting_context( + cell, + now, + WAITING_CO_STIMULATION, + evidence, + observation_id, + ) self._maybe_trace_co_stimulation( action="waiting_for_co_stimulation", evidence=evidence, @@ -540,8 +915,8 @@ def _process_candidate( match=match, details=( "score below threshold; keeping the cell in " - "antigen-recognized state until more corroborating " - "PAMPs arrive" + "antigen-recognized state and reevaluating on future " + "PAMP or DAMP evidence" ), metrics={ "score": co_stimulation["value"], @@ -567,7 +942,7 @@ def _process_candidate( context = self._compute_context_signals( responsible_ip, - observation_id, + reference_observation_id, candidate, match, now, @@ -577,7 +952,12 @@ def _process_candidate( now, last_effector_score=context["effector_score"], last_memory_score=context["memory_score"], - context={"co_stimulation": co_stimulation, "context": context}, + ) + cell = self._update_cell_context( + cell, + now, + co_stimulation=co_stimulation, + context=context, ) if context["effector"]: @@ -605,6 +985,7 @@ def _process_candidate( match=match, scores=context, ) + cell = self._clear_waiting_context(cell, now) self._apply_effector( cell, evidence, @@ -640,6 +1021,7 @@ def _process_candidate( match=match, scores=context, ) + cell = self._clear_waiting_context(cell, now) self._store_memory(cell, match, context, now) self._log_event( action="memory_stored", @@ -674,7 +1056,7 @@ def _process_candidate( from_state=cell["state"], to_state=STATE_MATURE, ) - self._transition_cell( + cell = self._transition_cell( cell=cell, to_state=STATE_MATURE, reason="context_timeout", @@ -688,8 +1070,16 @@ def _process_candidate( "wait_limit": self.state_wait_timeout_seconds, }, ) + cell = self._clear_waiting_context(cell, now) return match + cell = self._set_waiting_context( + cell, + now, + WAITING_CONTEXT, + evidence, + observation_id, + ) self._maybe_trace_context( action="waiting_for_context", evidence=evidence, @@ -715,7 +1105,8 @@ def _process_candidate( match=match, details=( "context is not strong enough yet for effector or memory; " - "keeping the current state and reevaluating on future PAMPs" + "keeping the current state and reevaluating on future PAMP " + "or DAMP evidence" ), metrics={ "effector_score": context["effector_score"], @@ -737,104 +1128,6 @@ def _process_candidate( ) return match - def _get_or_create_cell( - self, profile_ip: str, regex_type: str, antigen_value: str, now: float - ) -> dict: - cell_key = self._make_cell_key(profile_ip, regex_type, antigen_value) - cell = self.storage.get_cell(cell_key) - if cell: - return cell - - return { - "cell_key": cell_key, - "profile_ip": profile_ip, - "regex_type": regex_type, - "antigen_value": antigen_value, - "state": STATE_MATURE, - "state_name": STATE_INFO[STATE_MATURE]["label"], - "matched_regex_hash": None, - "matched_regex": None, - "matched_value": None, - "anergic_until": None, - "effector_cooldown_until": None, - "last_observation_id": None, - "last_evidence_id": None, - "last_transition_at": None, - "last_co_stimulation": None, - "last_effector_score": None, - "last_memory_score": None, - "context": {}, - "created_at": now, - "updated_at": now, - } - - def _transition_cell( - self, - cell: dict, - to_state: int, - reason: str, - evidence, - observation_id: int, - now: float, - match: RegexMatch | None = None, - scores: dict | None = None, - extra_updates: dict | None = None, - ) -> dict: - from_state = cell["state"] - updates = { - "state": to_state, - "state_name": STATE_INFO[to_state]["label"], - "last_observation_id": observation_id, - "last_evidence_id": evidence.id, - "last_transition_at": now, - } - if match: - updates.update( - { - "matched_regex_hash": match.regex_hash, - "matched_regex": match.regex, - "matched_value": match.value, - } - ) - if extra_updates: - updates.update(extra_updates) - - cell = self._update_cell(cell, now, **updates) - self.storage.insert_transition( - { - "cell_key": cell["cell_key"], - "profile_ip": cell["profile_ip"], - "regex_type": cell["regex_type"], - "antigen_value": cell["antigen_value"], - "evidence_id": evidence.id, - "observation_id": observation_id, - "from_state": from_state, - "to_state": to_state, - "reason": reason, - "matched_regex_hash": cell.get("matched_regex_hash"), - "matched_regex": cell.get("matched_regex"), - "matched_value": cell.get("matched_value"), - "scores": scores or {}, - "created_at": now, - } - ) - self._log_event( - action=reason, - state=to_state, - evidence=evidence, - cell=cell, - match=match, - metrics=scores, - verbosity=LOG_VERBOSITY_SUMMARY, - ) - return cell - - def _update_cell(self, cell: dict, now: float, **updates) -> dict: - cell.update(updates) - cell["updated_at"] = now - self.storage.upsert_cell(cell) - return cell - def _compute_co_stimulation( self, profile_ip: str, @@ -877,6 +1170,8 @@ def _compute_co_stimulation( return { "value": value, "confidence": confidence, + "confidence_observation_id": current_observation.get("id"), + "confidence_evidence_id": current_observation.get("evidence_id"), "related_pamp_count": related_pamp_count, "related_pamp_score": related_pamp_score, "profile_danger_score": profile_danger_score, @@ -1059,7 +1354,13 @@ def _maybe_trace_co_stimulation( self.co_stimulation_weights["confidence"] * co_stimulation["confidence"] ), - "evidence_id": evidence.id, + "evidence_id": co_stimulation.get( + "confidence_evidence_id" + ) + or evidence.id, + "observation_id": co_stimulation.get( + "confidence_observation_id" + ), }, "related_pamps": { "count": co_stimulation["related_pamp_count"], @@ -1416,7 +1717,12 @@ def _apply_effector( cell, now, effector_cooldown_until=next_cooldown, - context={"context": context, "effector_payload": blocking_data}, + ) + self._update_cell_context( + cell, + now, + context=context, + effector_payload=blocking_data, ) if self._blocking_modules_available(): @@ -1850,8 +2156,21 @@ def _resolve_trace_file_path(self, raw_path: str) -> str: safe_parts = ["t_cell_trace.jsonl"] return os.path.join(self.output_dir, *safe_parts) - def _colorize_state(self, state: int) -> str: + @staticmethod + def _get_waiting_label(cell: dict | None) -> str: + context = (cell or {}).get("context") or {} + waiting_for = context.get("waiting_for") + return WAITING_LABELS.get(waiting_for, "") + + def _format_state_label(self, state: int, cell: dict | None = None) -> str: label = STATE_INFO[state]["label"] + waiting_label = self._get_waiting_label(cell) + if waiting_label: + return f"{label} ({waiting_label})" + return label + + def _colorize_state(self, state: int, cell: dict | None = None) -> str: + label = self._format_state_label(state, cell) if not self.log_colors: return label return f"{STATE_INFO[state]['color']}{label}{COLOR_RESET}" @@ -1874,7 +2193,7 @@ def _log_event( f"action={action}", ] if state is not None: - parts.append(f"state={self._colorize_state(state)}") + parts.append(f"state={self._colorize_state(state, cell=cell)}") if evidence: parts.append(f"evidence={evidence.evidence_type.name}") parts.append(f"eid={evidence.id}") @@ -1888,6 +2207,9 @@ def _log_event( parts.append(f"target={target_ip}") if cell: parts.append(f"cell={cell['cell_key']}") + waiting_label = self._get_waiting_label(cell) + if waiting_label: + parts.append(f"waiting={waiting_label}") if match: parts.append(f"regex={match.regex_hash}") parts.append(f"value={match.value}") From 189ad9893c000b338949d912f5a5ce32c1fc069e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:54:57 +0000 Subject: [PATCH 0964/1100] feat: add method to retrieve cells for specific profile states in TCellSQLiteDB --- .../core/database/sqlite_db/t_cell_db.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/slips_files/core/database/sqlite_db/t_cell_db.py b/slips_files/core/database/sqlite_db/t_cell_db.py index bfd40367e1..8441bf8683 100644 --- a/slips_files/core/database/sqlite_db/t_cell_db.py +++ b/slips_files/core/database/sqlite_db/t_cell_db.py @@ -285,6 +285,27 @@ def get_all_cells(self) -> list[dict]: rows = self.select("cells", order_by="updated_at DESC") or [] return [self._row_to_cell(row) for row in rows] + def get_cells_for_profile_states( + self, profile_ip: str, states: list[int] | tuple[int, ...] + ) -> list[dict]: + normalized_states = [ + int(state) for state in (states or []) if state is not None + ] + if not normalized_states: + return [] + + placeholders = ", ".join("?" for _ in normalized_states) + rows = self.select( + "cells", + condition=( + f"profile_ip = ? AND state IN ({placeholders})" + ), + params=(profile_ip, *normalized_states), + order_by="updated_at DESC, created_at DESC", + ) + rows = rows or [] + return [self._row_to_cell(row) for row in rows] + def upsert_cell(self, record: dict): self.execute( "INSERT OR REPLACE INTO cells (" @@ -568,6 +589,11 @@ def get_cell(self, cell_key: str) -> dict | None: def get_all_cells(self) -> list[dict]: return self.db.get_all_cells() + def get_cells_for_profile_states( + self, profile_ip: str, states: list[int] | tuple[int, ...] + ) -> list[dict]: + return self.db.get_cells_for_profile_states(profile_ip, states) + def upsert_cell(self, record: dict): self.db.upsert_cell(record) From 6c2c837da579e121728d98bc9e745ca2b25e219e Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:13 +0000 Subject: [PATCH 0965/1100] feat: add upsert functionality for activated cell state and update report assertions --- .../modules/t_cell/test_analyze_t_cell.py | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index 454a5fb29a..ba4812dd59 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -151,6 +151,34 @@ def test_build_report_payload_and_html(tmp_path): "updated_at": 2000.3, } ) + storage.upsert_cell( + { + "cell_key": "192.168.1.121|tls_sni|arpanet-network.com", + "profile_ip": "192.168.1.121", + "regex_type": "tls_sni", + "antigen_value": "arpanet-network.com", + "state": 3, + "state_name": "3 - activated", + "matched_regex_hash": "regex-hash-2", + "matched_regex": r"arpanet-network\.com$", + "matched_value": "arpanet-network.com", + "anergic_until": None, + "effector_cooldown_until": None, + "last_observation_id": pamp_observation_id, + "last_evidence_id": "pamp-1", + "last_transition_at": 2000.4, + "last_co_stimulation": 1.0, + "last_effector_score": 0.70, + "last_memory_score": 0.40, + "context": { + "waiting_for": "context", + "waiting_since": 2000.4, + "wait_deadline": 2060.4, + }, + "created_at": 2000.4, + "updated_at": 2000.4, + } + ) storage.insert_transition( { "cell_key": cell_key, @@ -300,10 +328,15 @@ def test_build_report_payload_and_html(tmp_path): assert payload["totals"]["signals"] == {"DAMP": 1, "PAMP": 1} assert payload["totals"]["transitions"] == 3 assert payload["totals"]["memories"] == 1 - assert payload["cell_states"] == {"5 - memory": 1} + assert payload["cell_states"]["5 - memory"] == 1 + assert payload["cell_states"]["3 - activated"] == 1 assert payload["sources"]["trace_enabled"] is True assert payload["trace"]["total_rows"] == 2 assert payload["recent_observations"][0]["category"] == "PAMP with regex match" + assert any( + row["waiting_label"] == "waiting for context" + for row in payload["recent_cells"] + ) assert any( row["category"] == "DAMP with extracted antigens" for row in payload["recent_observations"] @@ -319,10 +352,11 @@ def test_build_report_payload_and_html(tmp_path): assert "Decision Trace" in html assert "T Cell State Machine" in html assert "regex match" in html - assert "current cells: 1" in html + assert "current cells:" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html assert "data-sortable-table='recent-transitions'" in html + assert "data-sortable-table='recent-cells'" in html assert "data-default-sort-column='4'" in html assert "Default order groups rows by T cell" in html assert "Click a column header to sort." in html @@ -332,5 +366,7 @@ def test_build_report_payload_and_html(tmp_path): assert "bad.example.com" in html assert "DAMP with extracted antigens" in html assert "PAMP with regex match" in html + assert "waiting for context" in html + assert "3 - activated (waiting for context)" not in html storage.close() From 648b0b460b6a311bc22c37cf54ffd4c615ae306d Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:22 +0000 Subject: [PATCH 0966/1100] feat: enhance DAMP evidence handling and add tests for waiting cell re-evaluation --- tests/unit/modules/t_cell/test_t_cell.py | 130 ++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/tests/unit/modules/t_cell/test_t_cell.py b/tests/unit/modules/t_cell/test_t_cell.py index 9f39d534c7..6e14d84668 100644 --- a/tests/unit/modules/t_cell/test_t_cell.py +++ b/tests/unit/modules/t_cell/test_t_cell.py @@ -228,7 +228,7 @@ def test_extract_antigen_candidates_from_entities_and_altflows(tmp_path): assert ("certificate_cn", "cn.bad.example.com") in extracted -def test_t_cell_ignores_damp_evidence(tmp_path): +def test_t_cell_stores_damp_evidence_and_checks_waiting_cells(tmp_path): t_cell, storage = _prepare_t_cell(tmp_path) evidence = _build_evidence("damp-1", signal=EvidenceSignal.DAMP) @@ -242,7 +242,8 @@ def test_t_cell_ignores_damp_evidence(tmp_path): t_cell.db.publish.assert_not_called() with open(t_cell.log_file_path, encoding="utf-8") as log_file: log_contents = log_file.read() - assert "ignored_non_pamp" in log_contents + assert "damp_reverification" in log_contents + assert "reevaluated_cells=0" in log_contents assert "signal=DAMP" in log_contents @@ -797,7 +798,7 @@ def test_t_cell_summary_log_hides_waiting_for_co_stimulation(tmp_path): def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): - t_cell, _ = _prepare_t_cell(tmp_path, log_verbosity=2) + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) evidence = _build_evidence("pending-2", uids=["dns-1"]) t_cell.db.get_altflow_from_uid.return_value = { "type_": "dns", @@ -813,12 +814,135 @@ def test_t_cell_decision_log_explains_waiting_for_co_stimulation(tmp_path): with open(t_cell.log_file_path, encoding="utf-8") as log_file: log_contents = log_file.read() + cell = storage.get_all_cells()[0] + assert cell["context"]["waiting_for"] == "co_stimulation" assert "waiting_for_co_stimulation" in log_contents + assert "waiting=waiting for co-stimulation" in log_contents assert "score=" in log_contents assert "threshold=" in log_contents assert "related_pamps=" in log_contents +def test_t_cell_damp_reverifies_waiting_co_stimulation_cells(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + fixed_now = 14_500.0 + profile_ip = "10.0.0.80" + evidence_pamp = _build_evidence( + "damp-reverify-costim-pamp", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + evidence_damp = _build_evidence( + "damp-reverify-costim-damp", + signal=EvidenceSignal.DAMP, + profile_ip=profile_ip, + threat_level=ThreatLevel.CRITICAL, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-reverify-costim-regex" + ) + _insert_observation( + storage=storage, + evidence_id="seed-damp-1", + profile_ip=profile_ip, + antigens=[], + observed_at=fixed_now - 20, + confidence=1.0, + threat_level_value=1.0, + threat_level="critical", + evidence_signal="DAMP", + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_pamp)) + + first_cell = storage.get_all_cells()[0] + assert first_cell["state"] == STATE_ANTIGEN_RECOGNIZED + assert first_cell["context"]["waiting_for"] == "co_stimulation" + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_damp)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_ACTIVATED + assert cell["context"]["waiting_for"] == "context" + assert any( + transition["reason"] == "co_stimulation_threshold_met" + and transition["evidence_id"] == evidence_damp.id + for transition in transitions + ) + + +def test_t_cell_damp_reverifies_waiting_context_cells(tmp_path): + t_cell, storage = _prepare_t_cell(tmp_path, log_verbosity=2) + fixed_now = 14_800.0 + profile_ip = "10.0.0.81" + antigen = AntigenCandidate(regex_type="dns_domain", value="bad.example.com") + evidence_pamp = _build_evidence( + "damp-reverify-context-pamp", + profile_ip=profile_ip, + uids=["dns-1"], + threat_level=ThreatLevel.LOW, + confidence=1.0, + ) + evidence_damp = _build_evidence( + "damp-reverify-context-damp", + signal=EvidenceSignal.DAMP, + profile_ip=profile_ip, + threat_level=ThreatLevel.CRITICAL, + confidence=1.0, + ) + t_cell.db.get_altflow_from_uid.return_value = { + "type_": "dns", + "query": "bad.example.com", + } + t_cell.db.get_generated_regexes.return_value = _accepted_domain_regex( + "damp-reverify-context-regex" + ) + t_cell.db.get_pid_of.side_effect = ( + lambda name: 123 if name == "Blocking" else None + ) + _seed_recent_related_observations( + storage, + profile_ip, + antigen, + fixed_now, + count=5, + confidence=1.0, + threat_level_value=0.1, + age_seconds=120, + ) + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now): + t_cell._process_evidence_message(_message_for(evidence_pamp)) + + first_cell = storage.get_all_cells()[0] + assert first_cell["state"] == STATE_ACTIVATED + assert first_cell["context"]["waiting_for"] == "context" + + with patch("modules.t_cell.t_cell.time.time", return_value=fixed_now + 10): + t_cell._process_evidence_message(_message_for(evidence_damp)) + + cell = storage.get_all_cells()[0] + transitions = storage.get_transitions(cell["cell_key"]) + assert cell["state"] == STATE_EFFECTOR + assert "waiting_for" not in cell["context"] + assert any( + transition["reason"] == "context_effector" + and transition["evidence_id"] == evidence_damp.id + for transition in transitions + ) + assert t_cell.db.publish.call_count == 1 + + def test_t_cell_log_file_contains_color_codes(tmp_path): t_cell, _ = _prepare_t_cell(tmp_path) evidence = _build_evidence("log-1") From e3a86e0a682b73891c35daab43078b2b6c495fbc Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 23 Mar 2026 15:55:30 +0000 Subject: [PATCH 0967/1100] feat: add T Cell report overview image to documentation --- docs/images/t_cell/t_cell_report_overview.png | Bin 0 -> 617207 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/t_cell/t_cell_report_overview.png diff --git a/docs/images/t_cell/t_cell_report_overview.png b/docs/images/t_cell/t_cell_report_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..82fcbf4649af910b57f2c9b1f1feaa56d0cae31b GIT binary patch literal 617207 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

    S_UTAO{Kq(wuHbDYV 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

    """ From 6af18c3fb97a452a9b98b775e656dd0d513a800f Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:36 +0200 Subject: [PATCH 0971/1100] feat: add script to analyze alert creation delay from Slips alerts exports --- scripts/analyze_alert_creation_delay.py | 632 ++++++++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100755 scripts/analyze_alert_creation_delay.py diff --git a/scripts/analyze_alert_creation_delay.py b/scripts/analyze_alert_creation_delay.py new file mode 100755 index 0000000000..40f0038db5 --- /dev/null +++ b/scripts/analyze_alert_creation_delay.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Analyze alert creation delay from Slips alerts exports. + +This script measures the delay between each alert's CreateTime and StartTime, +then summarizes the distribution and how it evolves over time. It supports the +newline-delimited JSON format used by alerts.json as well as plain JSON arrays. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import sys +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path + + +DEFAULT_RESOLUTIONS = ("day", "hour", "minute") +VALID_RESOLUTIONS = set(DEFAULT_RESOLUTIONS) +DELAY_BANDS = ( + ("negative", None, 0.0), + ("0s-1s", 0.0, 1.0), + ("1s-10s", 1.0, 10.0), + ("10s-60s", 10.0, 60.0), + ("1m-5m", 60.0, 300.0), + ("5m-1h", 300.0, 3600.0), + ("1h-1d", 3600.0, 86400.0), + (">=1d", 86400.0, None), +) + + +@dataclass(frozen=True) +class AlertDelayRecord: + record_number: int + alert_id: str + severity: str + create_time: str + start_time: str + delay_seconds: float + description: str + + +@dataclass(frozen=True) +class SummaryStats: + count: int + min_seconds: float + mean_seconds: float + p50_seconds: float + p90_seconds: float + p95_seconds: float + p99_seconds: float + max_seconds: float + + +@dataclass(frozen=True) +class BucketSummary: + bucket_start: str + count: int + min_seconds: float + mean_seconds: float + p50_seconds: float + p95_seconds: float + p99_seconds: float + max_seconds: float + + +def parse_args() -> argparse.Namespace: + class HelpFormatter( + argparse.ArgumentDefaultsHelpFormatter, + argparse.RawDescriptionHelpFormatter, + ): + pass + + parser = argparse.ArgumentParser( + description=( + "Analyze alert creation delay in Slips alerts exports.\n\n" + "The script reads alerts.json, computes the per-alert delay as\n" + "CreateTime - StartTime, then summarizes the overall distribution\n" + "and how that delay evolves over time by day, hour, and minute." + ), + epilog=( + "Input format:\n" + " alerts.json can be newline-delimited JSON (one alert per line)\n" + " or a regular JSON array of alert objects.\n\n" + "Outputs:\n" + " The terminal output shows overall statistics, delay bands,\n" + " the alerts with the largest delays, and trend tables.\n" + " If --output-dir is given, the script also writes CSV files for\n" + " each selected time resolution plus a summary.json file.\n\n" + "Example:\n" + " python3 scripts/analyze_alert_creation_delay.py \\\n" + " output/test-tcell-8/alerts.json \\\n" + " --output-dir output/test-tcell-8/alert_creation_delay_report" + ), + formatter_class=HelpFormatter, + ) + parser.add_argument( + "alerts_path", + help="Path to alerts.json (JSONL or JSON array).", + ) + parser.add_argument( + "--bucket-time", + choices=("create", "start"), + default="create", + help=( + "Which timestamp to use for trend buckets. Default: create " + "(group by CreateTime)." + ), + ) + parser.add_argument( + "--resolution", + action="append", + choices=sorted(VALID_RESOLUTIONS), + help=( + "Trend resolution to emit. Repeat to select a subset. " + "Default: day, hour, minute." + ), + ) + parser.add_argument( + "--output-dir", + default="", + help=( + "Optional directory where CSV trend files, top-delays CSV, and " + "summary.json will be written." + ), + ) + parser.add_argument( + "--print-limit", + type=int, + default=120, + help=( + "Print all buckets when a resolution has at most this many buckets. " + "Default: 120." + ), + ) + parser.add_argument( + "--top-buckets", + type=int, + default=10, + help=( + "When a resolution has many buckets, print this many worst buckets " + "and this many most recent buckets. Default: 10." + ), + ) + parser.add_argument( + "--top-alerts", + type=int, + default=10, + help="Show this many alerts with the largest delays. Default: 10.", + ) + parser.add_argument( + "--description-width", + type=int, + default=110, + help="Maximum description width in the top-alerts section. Default: 110.", + ) + return parser.parse_args() + + +def detect_input_format(path: Path) -> str: + with path.open(encoding="utf-8") as handle: + while True: + char = handle.read(1) + if not char: + raise ValueError(f"{path} is empty") + if char.isspace(): + continue + return "json-array" if char == "[" else "jsonl" + + +def iter_alert_records(path: Path): + input_format = detect_input_format(path) + if input_format == "json-array": + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, list): + raise ValueError(f"{path} is a JSON array file but did not contain a list") + for index, alert in enumerate(payload, start=1): + if not isinstance(alert, dict): + raise ValueError(f"Record {index} is not a JSON object") + yield input_format, index, alert + return + + with path.open(encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + stripped = line.strip() + if not stripped: + continue + try: + alert = json.loads(stripped) + except json.JSONDecodeError as exc: + raise ValueError( + f"Invalid JSON on line {line_number}: {exc.msg}" + ) from exc + if not isinstance(alert, dict): + raise ValueError(f"Line {line_number} is not a JSON object") + yield input_format, line_number, alert + + +def parse_timestamp(value: str) -> datetime: + normalized = value.replace("Z", "+00:00") + return datetime.fromisoformat(normalized) + + +def truncate_datetime(value: datetime, resolution: str) -> datetime: + if resolution == "day": + return value.replace(hour=0, minute=0, second=0, microsecond=0) + if resolution == "hour": + return value.replace(minute=0, second=0, microsecond=0) + if resolution == "minute": + return value.replace(second=0, microsecond=0) + raise ValueError(f"Unsupported resolution: {resolution}") + + +def percentile(sorted_values: list[float], fraction: float) -> float: + if not sorted_values: + raise ValueError("percentile() requires at least one value") + if len(sorted_values) == 1: + return sorted_values[0] + position = (len(sorted_values) - 1) * fraction + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return sorted_values[lower] + lower_value = sorted_values[lower] + upper_value = sorted_values[upper] + return lower_value + (upper_value - lower_value) * (position - lower) + + +def build_summary(values: list[float]) -> SummaryStats: + if not values: + raise ValueError("No values available to summarize") + ordered = sorted(values) + return SummaryStats( + count=len(ordered), + min_seconds=ordered[0], + mean_seconds=sum(ordered) / len(ordered), + p50_seconds=percentile(ordered, 0.50), + p90_seconds=percentile(ordered, 0.90), + p95_seconds=percentile(ordered, 0.95), + p99_seconds=percentile(ordered, 0.99), + max_seconds=ordered[-1], + ) + + +def build_bucket_summaries( + bucket_values: dict[datetime, list[float]] +) -> list[BucketSummary]: + summaries: list[BucketSummary] = [] + for bucket_start, values in sorted(bucket_values.items()): + ordered = sorted(values) + summaries.append( + BucketSummary( + bucket_start=bucket_start.isoformat(), + count=len(ordered), + min_seconds=ordered[0], + mean_seconds=sum(ordered) / len(ordered), + p50_seconds=percentile(ordered, 0.50), + p95_seconds=percentile(ordered, 0.95), + p99_seconds=percentile(ordered, 0.99), + max_seconds=ordered[-1], + ) + ) + return summaries + + +def delay_band_label(delay_seconds: float) -> str: + for label, lower, upper in DELAY_BANDS: + if lower is None and delay_seconds < upper: + return label + if upper is None and delay_seconds >= lower: + return label + if lower is not None and upper is not None and lower <= delay_seconds < upper: + return label + return "unclassified" + + +def ellipsize(text: str, width: int) -> str: + if width <= 3 or len(text) <= width: + return text + return text[: width - 3] + "..." + + +def print_summary_stats(summary: SummaryStats): + print("Overall delay statistics (CreateTime - StartTime, in seconds)") + print(f" alerts: {summary.count:,}") + print(f" min_s: {summary.min_seconds:.6f}") + print(f" mean_s: {summary.mean_seconds:.6f}") + print(f" p50_s: {summary.p50_seconds:.6f}") + print(f" p90_s: {summary.p90_seconds:.6f}") + print(f" p95_s: {summary.p95_seconds:.6f}") + print(f" p99_s: {summary.p99_seconds:.6f}") + print(f" max_s: {summary.max_seconds:.6f}") + + +def print_delay_bands(band_counts: dict[str, int], total: int): + print("\nDelay bands") + for label, _, _ in DELAY_BANDS: + count = band_counts.get(label, 0) + percentage = (count / total * 100) if total else 0.0 + print(f" {label:>8}: {count:>9,} ({percentage:6.2f}%)") + + +def print_top_alerts(top_alerts: list[AlertDelayRecord], description_width: int): + if not top_alerts: + return + print("\nLargest per-alert delays") + for rank, item in enumerate(top_alerts, start=1): + description = ellipsize(item.description.replace("\n", " "), description_width) + print( + f" {rank:>2}. delay_s={item.delay_seconds:>12.6f} " + f"record={item.record_number:<8} severity={item.severity or '-':<6} " + f"id={item.alert_id or '-'}" + ) + print( + f" start={item.start_time} create={item.create_time} " + f"description={description}" + ) + + +def print_bucket_table(rows: list[BucketSummary]): + if not rows: + print(" no buckets") + return + header = ( + f"{'bucket_start':<25} {'count':>8} {'min_s':>12} {'mean_s':>12} " + f"{'p50_s':>12} {'p95_s':>12} {'p99_s':>12} {'max_s':>12}" + ) + print(header) + print("-" * len(header)) + for row in rows: + print( + f"{row.bucket_start:<25} {row.count:>8,} " + f"{row.min_seconds:>12.3f} {row.mean_seconds:>12.3f} " + f"{row.p50_seconds:>12.3f} {row.p95_seconds:>12.3f} " + f"{row.p99_seconds:>12.3f} {row.max_seconds:>12.3f}" + ) + + +def print_resolution_summary( + resolution: str, + rows: list[BucketSummary], + print_limit: int, + top_buckets: int, + csv_path: Path | None, +): + print(f"\nBy {resolution}") + if not rows: + print(" no data") + return + + first_row = rows[0] + last_row = rows[-1] + print( + f" buckets: {len(rows):,}; first={first_row.bucket_start}; " + f"last={last_row.bucket_start}" + ) + print( + f" first mean/p50/p95: {first_row.mean_seconds:.3f} / " + f"{first_row.p50_seconds:.3f} / {first_row.p95_seconds:.3f} seconds" + ) + print( + f" last mean/p50/p95: {last_row.mean_seconds:.3f} / " + f"{last_row.p50_seconds:.3f} / {last_row.p95_seconds:.3f} seconds" + ) + if csv_path is not None: + print(f" csv: {csv_path}") + + if len(rows) <= print_limit: + print_bucket_table(rows) + return + + worst_rows = sorted( + rows, + key=lambda row: (row.p95_seconds, row.max_seconds, row.mean_seconds), + reverse=True, + )[:top_buckets] + recent_rows = rows[-top_buckets:] + + print(f" {len(rows):,} buckets exceed --print-limit={print_limit}.") + print(f" Worst {len(worst_rows)} buckets by p95_s") + print_bucket_table(sorted(worst_rows, key=lambda row: row.bucket_start)) + print(f"\n Most recent {len(recent_rows)} buckets") + print_bucket_table(recent_rows) + + +def write_bucket_csv(path: Path, rows: list[BucketSummary]): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "bucket_start", + "count", + "min_s", + "mean_s", + "p50_s", + "p95_s", + "p99_s", + "max_s", + ] + ) + for row in rows: + writer.writerow( + [ + row.bucket_start, + row.count, + f"{row.min_seconds:.6f}", + f"{row.mean_seconds:.6f}", + f"{row.p50_seconds:.6f}", + f"{row.p95_seconds:.6f}", + f"{row.p99_seconds:.6f}", + f"{row.max_seconds:.6f}", + ] + ) + + +def write_top_alerts_csv(path: Path, rows: list[AlertDelayRecord]): + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "record_number", + "alert_id", + "severity", + "create_time", + "start_time", + "delay_s", + "description", + ] + ) + for row in rows: + writer.writerow( + [ + row.record_number, + row.alert_id, + row.severity, + row.create_time, + row.start_time, + f"{row.delay_seconds:.6f}", + row.description, + ] + ) + + +def ensure_output_dir(output_dir: str) -> Path | None: + if not output_dir: + return None + path = Path(output_dir).expanduser().resolve() + path.mkdir(parents=True, exist_ok=True) + return path + + +def main() -> int: + args = parse_args() + alerts_path = Path(args.alerts_path).expanduser().resolve() + if not alerts_path.exists(): + print(f"alerts file not found: {alerts_path}", file=sys.stderr) + return 1 + + resolutions = tuple(args.resolution or DEFAULT_RESOLUTIONS) + output_dir = ensure_output_dir(args.output_dir) + + overall_delays: list[float] = [] + bucket_values = { + resolution: defaultdict(list) for resolution in resolutions + } + band_counts: dict[str, int] = defaultdict(int) + top_delay_records: list[AlertDelayRecord] = [] + skipped_missing_timestamps = 0 + skipped_invalid_timestamps = 0 + negative_count = 0 + zero_count = 0 + trend_min: datetime | None = None + trend_max: datetime | None = None + input_format: str | None = None + + for current_format, record_number, alert in iter_alert_records(alerts_path): + input_format = current_format + create_time_raw = alert.get("CreateTime") + start_time_raw = alert.get("StartTime") + if not create_time_raw or not start_time_raw: + skipped_missing_timestamps += 1 + continue + + try: + create_time = parse_timestamp(create_time_raw) + start_time = parse_timestamp(start_time_raw) + except ValueError: + skipped_invalid_timestamps += 1 + continue + + delay_seconds = (create_time - start_time).total_seconds() + overall_delays.append(delay_seconds) + band_counts[delay_band_label(delay_seconds)] += 1 + if delay_seconds < 0: + negative_count += 1 + elif delay_seconds == 0: + zero_count += 1 + + top_delay_records.append( + AlertDelayRecord( + record_number=record_number, + alert_id=str(alert.get("ID") or ""), + severity=str(alert.get("Severity") or ""), + create_time=create_time_raw, + start_time=start_time_raw, + delay_seconds=delay_seconds, + description=str(alert.get("Description") or ""), + ) + ) + + trend_time = create_time if args.bucket_time == "create" else start_time + if trend_min is None or trend_time < trend_min: + trend_min = trend_time + if trend_max is None or trend_time > trend_max: + trend_max = trend_time + for resolution in resolutions: + bucket_values[resolution][ + truncate_datetime(trend_time, resolution) + ].append(delay_seconds) + + if not overall_delays: + print( + ( + "No alerts with valid CreateTime and StartTime were found in " + f"{alerts_path}" + ), + file=sys.stderr, + ) + return 1 + + overall_summary = build_summary(overall_delays) + top_delay_records = sorted( + top_delay_records, + key=lambda item: item.delay_seconds, + reverse=True, + )[: args.top_alerts] + bucket_summaries = { + resolution: build_bucket_summaries(bucket_values[resolution]) + for resolution in resolutions + } + + csv_paths: dict[str, str] = {} + if output_dir is not None: + for resolution in resolutions: + csv_path = output_dir / f"alert_creation_delay_by_{resolution}.csv" + write_bucket_csv(csv_path, bucket_summaries[resolution]) + csv_paths[resolution] = str(csv_path) + + top_alerts_csv = output_dir / "alert_creation_delay_top_alerts.csv" + write_top_alerts_csv(top_alerts_csv, top_delay_records) + csv_paths["top_alerts"] = str(top_alerts_csv) + + summary_json = output_dir / "summary.json" + summary_payload = { + "alerts_path": str(alerts_path), + "input_format": input_format, + "bucket_time": args.bucket_time, + "resolutions": list(resolutions), + "processed_alerts": overall_summary.count, + "skipped_missing_timestamps": skipped_missing_timestamps, + "skipped_invalid_timestamps": skipped_invalid_timestamps, + "negative_delays": negative_count, + "zero_delays": zero_count, + "trend_start": trend_min.isoformat() if trend_min else None, + "trend_end": trend_max.isoformat() if trend_max else None, + "overall_delay_seconds": asdict(overall_summary), + "delay_bands": [ + { + "label": label, + "count": band_counts.get(label, 0), + "percentage": ( + band_counts.get(label, 0) / overall_summary.count * 100 + ), + } + for label, _, _ in DELAY_BANDS + ], + "top_delays": [asdict(item) for item in top_delay_records], + "csv_outputs": csv_paths, + "bucket_counts": { + resolution: len(bucket_summaries[resolution]) + for resolution in resolutions + }, + } + with summary_json.open("w", encoding="utf-8") as handle: + json.dump(summary_payload, handle, indent=2) + handle.write("\n") + csv_paths["summary_json"] = str(summary_json) + + print(f"Input: {alerts_path}") + print(f"Input format: {input_format}") + print(f"Trend bucket timestamp: {args.bucket_time} time") + print( + f"Valid alerts: {overall_summary.count:,}; skipped missing timestamps: " + f"{skipped_missing_timestamps:,}; skipped invalid timestamps: " + f"{skipped_invalid_timestamps:,}" + ) + if trend_min is not None and trend_max is not None: + print(f"Trend range: {trend_min.isoformat()} -> {trend_max.isoformat()}") + print( + f"Negative delays: {negative_count:,}; zero delays: {zero_count:,}" + ) + print_summary_stats(overall_summary) + print_delay_bands(band_counts, overall_summary.count) + print_top_alerts(top_delay_records, args.description_width) + + for resolution in resolutions: + csv_path = Path(csv_paths[resolution]) if resolution in csv_paths else None + print_resolution_summary( + resolution=resolution, + rows=bucket_summaries[resolution], + print_limit=args.print_limit, + top_buckets=args.top_buckets, + csv_path=csv_path, + ) + + if output_dir is not None: + print(f"\nArtifacts written to: {output_dir}") + print(f"Summary JSON: {csv_paths['summary_json']}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 47b02b414beffb60946439fbbdf423755405fcd6 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:45 +0200 Subject: [PATCH 0972/1100] feat: add regex auditing and pruning script for benign threshold management --- scripts/regex_prune_benign_threshold.py | 649 ++++++++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100755 scripts/regex_prune_benign_threshold.py diff --git a/scripts/regex_prune_benign_threshold.py b/scripts/regex_prune_benign_threshold.py new file mode 100755 index 0000000000..fe14cec516 --- /dev/null +++ b/scripts/regex_prune_benign_threshold.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2021 Sebastian Garcia +# SPDX-License-Identifier: GPL-2.0-only +""" +Audit and optionally prune accepted regexes that exceed the benign threshold. + +This is meant for persistent regex stores where the benign corpus may have +grown over time. A regex accepted earlier can later become too strong against +the current benign corpus even though it passed at generation time. +""" + +from __future__ import annotations + +import argparse +import json +import re +import signal +import shutil +import sqlite3 +import sys +import time +import warnings +from collections import defaultdict +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from slips_files.common.parsers.config_parser import ConfigParser +from slips_files.core.database.sqlite_db.regex_generator_db import REGEX_TYPES +from modules.regex_generator.match_strength import ( + compute_match_strength, + measure_regex_specificity, +) + + +@dataclass +class RegexAuditResult: + id: int + regex_type: str + regex: str + regex_hash: str + created_at: float + strongest_benign_score: float + strongest_benign_value: str + + +class _NullTimeout: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, exc_tb): + return False + + +class _SignalTimeout: + def __init__(self, timeout_seconds: float): + self.timeout_seconds = timeout_seconds + self._previous_handler = None + + def __enter__(self): + self._previous_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, self._handle_timeout) + signal.setitimer(signal.ITIMER_REAL, self.timeout_seconds) + return None + + def __exit__(self, exc_type, exc, exc_tb): + signal.setitimer(signal.ITIMER_REAL, 0) + if self._previous_handler is not None: + signal.signal(signal.SIGALRM, self._previous_handler) + return False + + @staticmethod + def _handle_timeout(signum, frame): + raise TimeoutError("regex benign scan timed out") + + +def timeout_context(timeout_seconds: float): + if timeout_seconds <= 0: + return _NullTimeout() + return _SignalTimeout(timeout_seconds) + + +class AuditProgressTracker: + BAR_WIDTH = 24 + + def __init__(self, total_regexes: int, totals_by_type: dict[str, int]): + self.total_regexes = max(1, total_regexes) + self.totals_by_type = dict(totals_by_type) + self.done_regexes = 0 + self.done_by_type = {regex_type: 0 for regex_type in totals_by_type} + self.current_type = "-" + self.comparisons_done = 0 + self.flagged_done = 0 + self.timed_out_done = 0 + self.started_at = time.monotonic() + self.last_render_at = 0.0 + self.enabled = sys.stderr.isatty() + + def start(self): + if not self.enabled: + return + print( + ( + "Auditing accepted regexes against the current benign corpus " + f"({self.total_regexes} regexes)" + ), + file=sys.stderr, + flush=True, + ) + self._render(force=True) + + def advance( + self, + regex_type: str, + comparisons: int, + flagged_increment: int = 0, + timed_out_increment: int = 0, + ): + self.done_regexes += 1 + self.current_type = regex_type + self.comparisons_done += comparisons + self.flagged_done += flagged_increment + self.timed_out_done += timed_out_increment + self.done_by_type[regex_type] = self.done_by_type.get(regex_type, 0) + 1 + self._render() + + def finish(self): + if not self.enabled: + return + self._render(force=True, done=True) + print(file=sys.stderr, flush=True) + + def _render(self, force: bool = False, done: bool = False): + if not self.enabled: + return + + now = time.monotonic() + if not force and not done and now - self.last_render_at < 0.1: + return + self.last_render_at = now + + ratio = min(1.0, self.done_regexes / self.total_regexes) + filled = int(ratio * self.BAR_WIDTH) + bar = "[" + ("=" * filled) + ("." * (self.BAR_WIDTH - filled)) + "]" + elapsed = max(0.001, now - self.started_at) + if done or ratio >= 1.0: + eta = 0.0 + else: + eta = (elapsed / max(ratio, 1e-9)) - elapsed + + type_done = self.done_by_type.get(self.current_type, 0) + type_total = self.totals_by_type.get(self.current_type, 0) + status = ( + "\r" + f"{bar} {ratio * 100:6.2f}% " + f"| regex {self.done_regexes}/{self.total_regexes} " + f"| type {self.current_type} {type_done}/{type_total} " + f"| flagged {self.flagged_done} " + f"| timed out {self.timed_out_done} " + f"| cmp {self.comparisons_done:,} " + f"| ETA {self._format_duration(eta)}" + ) + print(status, end="", file=sys.stderr, flush=True) + + @staticmethod + def _format_duration(seconds: float) -> str: + total_seconds = max(0, int(seconds)) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Audit accepted regexes against the current benign corpus and " + "optionally delete those whose strongest benign match meets or " + "exceeds the configured threshold." + ) + ) + parser.add_argument( + "--run-output-dir", + default="", + help=( + "Slips run output directory containing regex_generator/*.sqlite, " + "or a direct regex store directory containing generated_regexes.sqlite " + "and benign_corpus.sqlite." + ), + ) + parser.add_argument( + "--regex-db", + default="", + help="Path to generated_regexes.sqlite. Overrides --run-output-dir.", + ) + parser.add_argument( + "--benign-db", + default="", + help="Path to benign_corpus.sqlite. Overrides --run-output-dir.", + ) + parser.add_argument( + "--threshold", + type=float, + default=None, + help=( + "Benign match-strength threshold. Defaults to " + "regex_generator.benign_match_strength_threshold from config, " + "or 75 if unavailable." + ), + ) + parser.add_argument( + "--regex-type", + action="append", + choices=sorted(REGEX_TYPES), + help="Limit the audit to one or more regex types.", + ) + parser.add_argument( + "--match-timeout-seconds", + type=float, + default=None, + help=( + "Maximum wall-clock seconds allowed for one accepted regex to scan " + "the benign corpus for its regex type. Timed-out regexes are " + "skipped and never deleted. Defaults to " + "regex_generator.regex_validation_timeout_seconds from config, " + "or 2.0 if unavailable. Set 0 to disable." + ), + ) + parser.add_argument( + "--limit", + type=int, + default=20, + help="Maximum number of example rows to print per regex type.", + ) + parser.add_argument( + "--output-json", + default="", + help="Optional JSON output path for the audit summary.", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete accepted regex rows that exceed the threshold.", + ) + parser.add_argument( + "--no-backup", + action="store_true", + help="Do not create a backup copy of generated_regexes.sqlite before deletion.", + ) + parser.add_argument( + "--vacuum", + action="store_true", + help="Run VACUUM on generated_regexes.sqlite after deletion.", + ) + return parser.parse_args() + + +def default_threshold() -> float: + try: + return float( + ConfigParser().regex_generator_benign_match_strength_threshold() + ) + except Exception: + return 75.0 + + +def default_match_timeout() -> float: + try: + return float(ConfigParser().regex_generator_regex_validation_timeout_seconds()) + except Exception: + return 2.0 + + +def resolve_paths(args: argparse.Namespace) -> tuple[Path, Path]: + if args.regex_db and args.benign_db: + return Path(args.regex_db).expanduser(), Path(args.benign_db).expanduser() + + if not args.run_output_dir: + raise SystemExit( + "Provide either --regex-db and --benign-db, or --run-output-dir." + ) + + base = Path(args.run_output_dir).expanduser() + direct_regex = base / "generated_regexes.sqlite" + direct_benign = base / "benign_corpus.sqlite" + nested_regex = base / "regex_generator" / "generated_regexes.sqlite" + nested_benign = base / "regex_generator" / "benign_corpus.sqlite" + + if direct_regex.exists() and direct_benign.exists(): + return direct_regex, direct_benign + if nested_regex.exists() and nested_benign.exists(): + return nested_regex, nested_benign + + raise SystemExit( + "Could not find regex DBs. Checked:\n" + f"- {direct_regex} and {direct_benign}\n" + f"- {nested_regex} and {nested_benign}" + ) + + +def load_benign_values(benign_db_path: Path) -> dict[str, list[str]]: + benign_values = {regex_type: [] for regex_type in REGEX_TYPES} + with sqlite3.connect(benign_db_path) as conn: + rows = conn.execute( + "SELECT regex_type, value FROM benign_strings ORDER BY id ASC" + ) + for regex_type, value in rows: + benign_values.setdefault(regex_type, []).append(str(value or "")) + return benign_values + + +def load_accepted_regexes( + regex_db_path: Path, regex_types: set[str] +) -> dict[str, list[dict]]: + accepted = defaultdict(list) + with sqlite3.connect(regex_db_path) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT id, regex_type, regex, regex_hash, created_at + FROM generated_regexes + WHERE status = 'accepted' + ORDER BY created_at ASC, id ASC + """ + ).fetchall() + for row in rows: + regex_type = row["regex_type"] + if regex_type not in regex_types: + continue + accepted[regex_type].append(dict(row)) + return accepted + + +def audit_regex_type( + regex_rows: list[dict], + benign_values: list[str], + threshold: float, + match_timeout_seconds: float, + progress: AuditProgressTracker | None = None, +) -> tuple[list[RegexAuditResult], list[dict]]: + flagged = [] + timed_out = [] + for row in regex_rows: + comparisons_checked = 0 + flagged_increment = 0 + timed_out_increment = 0 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + compiled = re.compile(row["regex"]) + except re.error: + if progress is not None: + progress.advance( + row["regex_type"], + comparisons=comparisons_checked, + flagged_increment=flagged_increment, + timed_out_increment=timed_out_increment, + ) + continue + + regex_features = measure_regex_specificity(row["regex"]) + best_score = 0.0 + best_value = "" + try: + with timeout_context(match_timeout_seconds): + for value in benign_values: + comparisons_checked += 1 + score = compute_match_strength(compiled, value, regex_features) + if score > best_score: + best_score = score + best_value = value + if best_score >= threshold: + flagged_increment = 1 + flagged.append( + RegexAuditResult( + id=int(row["id"]), + regex_type=row["regex_type"], + regex=row["regex"], + regex_hash=row["regex_hash"], + created_at=float(row["created_at"]), + strongest_benign_score=best_score, + strongest_benign_value=best_value, + ) + ) + break + except TimeoutError: + timed_out_increment = 1 + timed_out.append( + { + "id": int(row["id"]), + "regex_type": row["regex_type"], + "regex": row["regex"], + "regex_hash": row["regex_hash"], + "created_at": float(row["created_at"]), + "comparisons_checked": comparisons_checked, + } + ) + if progress is not None: + progress.advance( + row["regex_type"], + comparisons=comparisons_checked, + flagged_increment=flagged_increment, + timed_out_increment=timed_out_increment, + ) + flagged.sort( + key=lambda item: ( + item.regex_type, + item.strongest_benign_score, + item.created_at, + item.id, + ), + reverse=True, + ) + timed_out.sort( + key=lambda item: ( + item["regex_type"], + item["created_at"], + item["id"], + ), + reverse=True, + ) + return flagged, timed_out + + +def backup_regex_db(regex_db_path: Path) -> Path: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_path = regex_db_path.with_suffix(regex_db_path.suffix + f".bak.{timestamp}") + shutil.copy2(regex_db_path, backup_path) + return backup_path + + +def delete_flagged_regexes( + regex_db_path: Path, flagged_results: list[RegexAuditResult], vacuum: bool +) -> int: + ids = [result.id for result in flagged_results] + if not ids: + return 0 + + placeholders = ",".join("?" for _ in ids) + with sqlite3.connect(regex_db_path) as conn: + cursor = conn.execute( + f"DELETE FROM generated_regexes WHERE id IN ({placeholders})", + ids, + ) + deleted = int(cursor.rowcount or 0) + conn.commit() + if vacuum: + conn.execute("VACUUM") + return deleted + + +def build_summary( + regex_db_path: Path, + benign_db_path: Path, + threshold: float, + regex_types: list[str], + accepted_by_type: dict[str, list[dict]], + flagged_by_type: dict[str, list[RegexAuditResult]], + timed_out_by_type: dict[str, list[dict]], + limit: int, + deleted: int, + backup_path: Path | None, + match_timeout_seconds: float, +) -> dict: + summary_types = {} + for regex_type in regex_types: + flagged_rows = flagged_by_type.get(regex_type, []) + timed_out_rows = timed_out_by_type.get(regex_type, []) + summary_types[regex_type] = { + "accepted_count": len(accepted_by_type.get(regex_type, [])), + "flagged_count": len(flagged_rows), + "timed_out_count": len(timed_out_rows), + "examples": [ + { + **asdict(result), + "created_at_iso": datetime.fromtimestamp( + result.created_at, tz=timezone.utc + ).isoformat(), + } + for result in flagged_rows[:limit] + ], + "timed_out_examples": [ + { + **row, + "created_at_iso": datetime.fromtimestamp( + row["created_at"], tz=timezone.utc + ).isoformat(), + } + for row in timed_out_rows[:limit] + ], + } + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "regex_db_path": str(regex_db_path), + "benign_db_path": str(benign_db_path), + "threshold": threshold, + "match_timeout_seconds": match_timeout_seconds, + "regex_types": regex_types, + "deleted_count": deleted, + "backup_path": str(backup_path) if backup_path else "", + "totals": { + "accepted_count": sum( + len(accepted_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + "flagged_count": sum( + len(flagged_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + "timed_out_count": sum( + len(timed_out_by_type.get(regex_type, [])) + for regex_type in regex_types + ), + }, + "types": summary_types, + } + + +def print_summary(summary: dict, delete_mode: bool): + action = "Deleted" if delete_mode else "Flagged" + print( + f"Threshold: {summary['threshold']:.2f}\n" + f"Match timeout per regex: {summary['match_timeout_seconds']:.2f}s\n" + f"Regex DB: {summary['regex_db_path']}\n" + f"Benign DB: {summary['benign_db_path']}\n" + f"Accepted rows scanned: {summary['totals']['accepted_count']}\n" + f"{action} rows: {summary['totals']['flagged_count']}\n" + f"Timed-out rows skipped: {summary['totals']['timed_out_count']}" + ) + print( + "Accepted means rows currently stored in generated_regexes.sqlite " + "with status='accepted'." + ) + if delete_mode: + print( + "Deleted means accepted rows whose strongest benign match score " + "met or exceeded the threshold and were removed." + ) + else: + print( + "Flagged means accepted rows whose strongest benign match score " + "meets or exceeds the threshold against the current benign corpus." + ) + if summary.get("backup_path"): + print(f"Backup: {summary['backup_path']}") + + for regex_type in summary["regex_types"]: + row = summary["types"][regex_type] + print( + f"\n[{regex_type}] accepted={row['accepted_count']} " + f"flagged={row['flagged_count']} " + f"timed_out={row['timed_out_count']}" + ) + for example in row["examples"]: + print( + " " + f"score={example['strongest_benign_score']:.2f} " + f"value={example['strongest_benign_value']} " + f"created_at={example['created_at_iso']} " + f"regex={example['regex']}" + ) + for example in row["timed_out_examples"]: + print( + " " + "timed_out " + f"after_cmp={example['comparisons_checked']} " + f"created_at={example['created_at_iso']} " + f"regex={example['regex']}" + ) + + +def main(): + args = parse_args() + regex_db_path, benign_db_path = resolve_paths(args) + threshold = ( + float(args.threshold) if args.threshold is not None else default_threshold() + ) + match_timeout_seconds = ( + float(args.match_timeout_seconds) + if args.match_timeout_seconds is not None + else default_match_timeout() + ) + regex_types = sorted(set(args.regex_type or REGEX_TYPES)) + + benign_values = load_benign_values(benign_db_path) + accepted_by_type = load_accepted_regexes(regex_db_path, set(regex_types)) + progress = AuditProgressTracker( + total_regexes=sum( + len(accepted_by_type.get(regex_type, [])) for regex_type in regex_types + ), + totals_by_type={ + regex_type: len(accepted_by_type.get(regex_type, [])) + for regex_type in regex_types + }, + ) + progress.start() + flagged_by_type = {} + timed_out_by_type = {} + for regex_type in regex_types: + flagged_rows, timed_out_rows = audit_regex_type( + accepted_by_type.get(regex_type, []), + benign_values.get(regex_type, []), + threshold, + match_timeout_seconds, + progress=progress, + ) + flagged_by_type[regex_type] = flagged_rows + timed_out_by_type[regex_type] = timed_out_rows + progress.finish() + + backup_path = None + deleted = 0 + flagged_results = [ + result + for regex_type in regex_types + for result in flagged_by_type.get(regex_type, []) + ] + if args.delete and flagged_results: + if not args.no_backup: + backup_path = backup_regex_db(regex_db_path) + deleted = delete_flagged_regexes(regex_db_path, flagged_results, args.vacuum) + + summary = build_summary( + regex_db_path=regex_db_path, + benign_db_path=benign_db_path, + threshold=threshold, + regex_types=regex_types, + accepted_by_type=accepted_by_type, + flagged_by_type=flagged_by_type, + timed_out_by_type=timed_out_by_type, + limit=max(0, args.limit), + deleted=deleted, + backup_path=backup_path, + match_timeout_seconds=match_timeout_seconds, + ) + print_summary(summary, delete_mode=args.delete) + + if args.output_json: + output_path = Path(args.output_json).expanduser() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() From d6f88425b1fae3a537cc5d99a2ea146229b517a2 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Mon, 30 Mar 2026 08:05:52 +0200 Subject: [PATCH 0973/1100] feat: enhance report HTML assertions for regex and co-stimulation states --- tests/unit/modules/t_cell/test_analyze_t_cell.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/modules/t_cell/test_analyze_t_cell.py b/tests/unit/modules/t_cell/test_analyze_t_cell.py index ba4812dd59..6767cd269b 100644 --- a/tests/unit/modules/t_cell/test_analyze_t_cell.py +++ b/tests/unit/modules/t_cell/test_analyze_t_cell.py @@ -351,7 +351,11 @@ def test_build_report_payload_and_html(tmp_path): assert "Quick Summary" in html assert "Decision Trace" in html assert "T Cell State Machine" in html - assert "regex match" in html + assert "accepted regex match" in html + assert "no accepted regex match" in html + assert "stays mature" in html + assert "co-stimulation below threshold" in html + assert "no co-stimulation timeout" in html assert "current cells:" in html assert "Module Log Tail" not in html assert "data-sortable-table='recent-observations'" in html From ac46a57bf7fd48e0f22490eb7ca2765c03785114 Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sun, 19 Apr 2026 09:56:07 +0200 Subject: [PATCH 0974/1100] feat: enable decision trace mode for T Cell responder module --- config/slips.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/slips.yaml b/config/slips.yaml index 427cf8e39d..e8254414be 100644 --- a/config/slips.yaml +++ b/config/slips.yaml @@ -391,7 +391,7 @@ t_cell: # off = disabled # transitions = write detailed traces only when a state transition happens # all = also trace waiting evaluations - decision_trace_mode: off + decision_trace_mode: on # Separate trace file used only when decision_trace_mode is not off. # This path is always resolved inside the selected output directory for the From cade04599635b71f2c4eba5b3a9e603abe495e9a Mon Sep 17 00:00:00 2001 From: Seba Garcia Date: Sun, 19 Apr 2026 09:56:25 +0200 Subject: [PATCH 0975/1100] Implement feature X to enhance user experience and fix bug Y in module Z --- modules/t_cell/analyze_t_cell.py | 1779 +++++++++++++++++++++++++++++- 1 file changed, 1732 insertions(+), 47 deletions(-) diff --git a/modules/t_cell/analyze_t_cell.py b/modules/t_cell/analyze_t_cell.py index d0facc12e5..9ba9a80038 100644 --- a/modules/t_cell/analyze_t_cell.py +++ b/modules/t_cell/analyze_t_cell.py @@ -62,6 +62,29 @@ "co_stimulation": "waiting for co-stimulation", "context": "waiting for context", } +DEFAULT_COSTIM_WEIGHTS = { + "confidence": 0.35, + "related_pamps": 0.25, + "danger": 0.40, +} +DEFAULT_DOC_CONFIG = { + "anergy_ttl_seconds": 21600.0, + "related_lookback_seconds": 3600.0, + "related_pamps_saturation": 5.0, + "danger_saturation": 2.5, + "damp_danger_weight": 1.5, + "co_stimulation_threshold": 0.65, + "co_stimulation_weights": DEFAULT_COSTIM_WEIGHTS, + "novelty_window_seconds": 86400.0, + "context_recent_window_seconds": 1800.0, + "effector_threshold": 0.70, + "effector_min_related_count": 4, + "effector_cooldown_seconds": 1800.0, + "memory_threshold": 0.60, + "memory_trend_ratio_max": 0.60, + "memory_min_related_count": 3, + "state_wait_timeout_seconds": 3600.0, +} def parse_args() -> argparse.Namespace: @@ -451,6 +474,54 @@ def safe_div(num: float, den: float) -> float: return num / den +def normalize_costim_weights(weights: Any) -> dict[str, float]: + if not isinstance(weights, dict): + weights = {} + sanitized = {} + for key, default_value in DEFAULT_COSTIM_WEIGHTS.items(): + raw_value = weights.get(key, default_value) + try: + raw_value = float(raw_value) + except (TypeError, ValueError): + raw_value = default_value + sanitized[key] = max(0.0, raw_value) + + total = sum(sanitized.values()) + if total <= 0: + total = sum(DEFAULT_COSTIM_WEIGHTS.values()) + sanitized = DEFAULT_COSTIM_WEIGHTS.copy() + return {key: value / total for key, value in sanitized.items()} + + +def coerce_time_window_width(raw_value: Any) -> float: + if raw_value in (None, ""): + return float(DEFAULT_DOC_CONFIG["state_wait_timeout_seconds"]) + try: + return float(raw_value) + except (TypeError, ValueError): + text = str(raw_value) + if "only_one_tw" in text: + return 9999999999.0 + return float(DEFAULT_DOC_CONFIG["state_wait_timeout_seconds"]) + + +def report_config_with_defaults(report: dict) -> dict: + config = dict(report.get("config") or {}) + merged = {} + for key, default_value in DEFAULT_DOC_CONFIG.items(): + raw_value = config.get(key, default_value) + if key == "co_stimulation_weights": + merged[key] = normalize_costim_weights(raw_value) + continue + if key == "state_wait_timeout_seconds": + merged[key] = coerce_time_window_width(raw_value) + continue + if raw_value in (None, "", {}): + raw_value = default_value + merged[key] = raw_value + return merged + + def build_findings(report: dict) -> list[str]: totals = report["totals"] categories = report["observation_categories"] @@ -612,6 +683,523 @@ def bucket_items( } +def trace_row_cell_key(entry: dict) -> str: + if entry.get("cell_key"): + return str(entry.get("cell_key")) + candidate = entry.get("candidate") or {} + responsible_ip = str(entry.get("responsible_ip") or "") + regex_type = str(candidate.get("regex_type") or "") + antigen_value = str(candidate.get("value") or "") + if responsible_ip and regex_type and antigen_value: + return f"{responsible_ip}|{regex_type}|{antigen_value}" + return "" + + +def describe_current_evidence(current_evidence: dict | None) -> str: + current_evidence = current_evidence or {} + evidence_id = current_evidence.get("evidence_id") or "n/a" + evidence_type = current_evidence.get("evidence_type") or "unknown" + signal = current_evidence.get("signal") or "unknown" + confidence = format_float(current_evidence.get("confidence")) + threat_level = current_evidence.get("threat_level") or "unknown" + threat_level_value = format_float(current_evidence.get("threat_level_value")) + danger = format_float(current_evidence.get("danger_contribution")) + observation_id = current_evidence.get("observation_id") + observation_part = ( + f"obs={observation_id} | " if observation_id not in (None, "") else "" + ) + return ( + f"{observation_part}eid={evidence_id} | {evidence_type} | {signal} | " + f"conf={confidence} | threat={threat_level} ({threat_level_value}) | " + f"danger={danger}" + ) + + +def describe_observation_row(observation: dict | None) -> str: + observation = observation or {} + if not observation: + return "no linked observation row" + return ( + f"obs={observation.get('id')} | eid={observation.get('evidence_id')} | " + f"{observation.get('evidence_type')} | {observation.get('evidence_signal')} | " + f"conf={format_float(observation.get('confidence'))} | " + f"threat={observation.get('threat_level')} " + f"({format_float(observation.get('threat_level_value'))}) | " + f"antigens={summarize_antigens(observation.get('antigens') or [])} | " + f"matches={summarize_matched_regexes(observation.get('matched_regexes') or [])}" + ) + + +def describe_trace_contributor(prefix: str, contributor: dict) -> str: + relations = contributor.get("relations") or [] + relations_text = f" | relations={','.join(relations)}" if relations else "" + return ( + f"{prefix}: obs={contributor.get('observation_id')} | " + f"eid={contributor.get('evidence_id')} | {contributor.get('evidence_type')} | " + f"{contributor.get('signal')} | conf={format_float(contributor.get('confidence'))} | " + f"threat={contributor.get('threat_level')} " + f"({format_float(contributor.get('threat_level_value'))}) | " + f"danger={format_float(contributor.get('danger_contribution'))}" + f"{relations_text}" + ) + + +def summarize_lines(lines: list[str], fallback: str = "n/a", limit: int = 2) -> str: + cleaned = [str(line).strip() for line in lines if str(line).strip()] + if not cleaned: + return fallback + summary = " | ".join(cleaned[:limit]) + if len(cleaned) > limit: + summary += f" | +{len(cleaned) - limit} more" + return summary + + +def generic_threshold_result(scores: dict) -> tuple[str, list[str]]: + if not isinstance(scores, dict): + return ("n/a", ["No threshold snapshot was stored for this transition."]) + + if "value" in scores and "threshold" in scores: + value = scores.get("value") + threshold = scores.get("threshold") + passed = float(value) >= float(threshold) + comparator = ">=" if passed else "<" + status = "passed" if passed else "failed" + return ( + f"{status}: {format_float(value)} {comparator} {format_float(threshold)}", + [ + f"value={format_float(value)}", + f"threshold={format_float(threshold)}", + ], + ) + + result_lines = [] + summary_bits = [] + if "effector_score" in scores and "effector_threshold" in scores: + passed = float(scores["effector_score"]) >= float(scores["effector_threshold"]) + summary_bits.append( + "effector " + + ("passed" if passed else "failed") + + f": {format_float(scores['effector_score'])} " + + (">=" if passed else "<") + + f" {format_float(scores['effector_threshold'])}" + ) + result_lines.append(summary_bits[-1]) + if "memory_score" in scores and "memory_threshold" in scores: + passed = float(scores["memory_score"]) >= float(scores["memory_threshold"]) + summary_bits.append( + "memory " + + ("passed" if passed else "failed") + + f": {format_float(scores['memory_score'])} " + + (">=" if passed else "<") + + f" {format_float(scores['memory_threshold'])}" + ) + result_lines.append(summary_bits[-1]) + if not summary_bits: + return ("n/a", ["No threshold keys were stored in this score snapshot."]) + return (" | ".join(summary_bits), result_lines) + + +def build_trace_threshold_result(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + action = entry.get("action") or "" + if stage == "co_stimulation": + value = formula.get("value") + threshold = formula.get("threshold") + if value is None or threshold is None: + return ("n/a", ["Missing co-stimulation value or threshold."]) + passed = float(value) >= float(threshold) + comparator = ">=" if passed else "<" + return ( + f"{'passed' if passed else 'failed'}: " + f"{format_float(value)} {comparator} {format_float(threshold)}", + [ + f"action={action}", + f"value={format_float(value)}", + f"threshold={format_float(threshold)}", + ], + ) + + if stage == "context": + decision = formula.get("decision") or {} + effector = bool(decision.get("effector")) + memory = bool(decision.get("memory")) + effector_score = formula.get("effector_score") + effector_threshold = formula.get("effector_threshold") + memory_score = formula.get("memory_score") + memory_threshold = formula.get("memory_threshold") + summary = ( + f"effector={'yes' if effector else 'no'} " + f"({format_float(effector_score)} / {format_float(effector_threshold)}) | " + f"memory={'yes' if memory else 'no'} " + f"({format_float(memory_score)} / {format_float(memory_threshold)})" + ) + return ( + summary, + [ + f"action={action}", + f"effector decision={'passed' if effector else 'failed'}", + f"memory decision={'passed' if memory else 'failed'}", + f"effector_score={format_float(effector_score)} threshold={format_float(effector_threshold)}", + f"memory_score={format_float(memory_score)} threshold={format_float(memory_threshold)}", + ], + ) + return ("n/a", ["No threshold formatter for this trace stage."]) + + +def build_trace_considered_evidence(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + lines = [] + current_evidence = entry.get("current_evidence") or {} + if current_evidence: + lines.append("current: " + describe_current_evidence(current_evidence)) + + components = formula.get("components") or {} + if stage == "co_stimulation": + related = (components.get("related_pamps") or {}).get("contributors") or [] + danger = components.get("danger") or {} + pamp_contributors = danger.get("pamp_contributors") or [] + damp_contributors = danger.get("damp_contributors") or [] + for contributor in related: + lines.append(describe_trace_contributor("related_pamp", contributor)) + for contributor in pamp_contributors: + lines.append(describe_trace_contributor("danger_pamp", contributor)) + for contributor in damp_contributors: + lines.append(describe_trace_contributor("danger_damp", contributor)) + elif stage == "context": + recent_related = (components.get("recent_related") or {}).get( + "contributors" + ) or [] + recent_pressure = components.get("recent_pressure") or {} + previous_pressure = components.get("previous_pressure") or {} + for contributor in recent_related: + lines.append(describe_trace_contributor("recent_related", contributor)) + for contributor in recent_pressure.get("pamp_contributors") or []: + lines.append(describe_trace_contributor("recent_pressure_pamp", contributor)) + for contributor in recent_pressure.get("damp_contributors") or []: + lines.append(describe_trace_contributor("recent_pressure_damp", contributor)) + for contributor in previous_pressure.get("pamp_contributors") or []: + lines.append(describe_trace_contributor("previous_pressure_pamp", contributor)) + for contributor in previous_pressure.get("damp_contributors") or []: + lines.append(describe_trace_contributor("previous_pressure_damp", contributor)) + + if not lines: + lines.append("No contributor evidence snapshot was stored for this event.") + return (summarize_lines(lines, fallback="no stored evidence inputs"), lines) + + +def build_trace_computation_lines(entry: dict) -> tuple[str, list[str]]: + formula = entry.get("formula") or {} + stage = entry.get("stage") + if stage == "co_stimulation": + components = formula.get("components") or {} + confidence = components.get("confidence") or {} + related = components.get("related_pamps") or {} + danger = components.get("danger") or {} + lines = [ + f"value={format_float(formula.get('value'))}", + f"threshold={format_float(formula.get('threshold'))}", + ( + "confidence: value=" + f"{format_float(confidence.get('value'))} weighted=" + f"{format_float(confidence.get('weighted'))}" + ), + ( + "related_pamps: count=" + f"{related.get('count', 'n/a')} saturation=" + f"{format_float(related.get('saturation'))} score=" + f"{format_float(related.get('score'))} weighted=" + f"{format_float(related.get('weighted'))}" + ), + ( + "danger: score=" + f"{format_float(danger.get('score'))} weighted=" + f"{format_float(danger.get('weighted'))} pamp_score=" + f"{format_float(danger.get('pamp_score'))} damp_score=" + f"{format_float(danger.get('damp_score'))} damp_weight=" + f"{format_float(danger.get('damp_weight'))} saturation=" + f"{format_float(danger.get('danger_saturation'))}" + ), + ] + return (summarize_trace_formula(formula, stage), lines) + + if stage == "context": + components = formula.get("components") or {} + novelty = components.get("novelty") or {} + recent_related = components.get("recent_related") or {} + recent_pressure = components.get("recent_pressure") or {} + previous_pressure = components.get("previous_pressure") or {} + lines = [ + ( + "effector_score=" + f"{format_float(formula.get('effector_score'))} threshold=" + f"{format_float(formula.get('effector_threshold'))}" + ), + ( + "memory_score=" + f"{format_float(formula.get('memory_score'))} threshold=" + f"{format_float(formula.get('memory_threshold'))}" + ), + ( + "decision flags: effector=" + f"{'yes' if (formula.get('decision') or {}).get('effector') else 'no'} " + "memory=" + f"{'yes' if (formula.get('decision') or {}).get('memory') else 'no'}" + ), + ( + "novelty: score=" + f"{format_float(novelty.get('score'))} has_memory=" + f"{'yes' if novelty.get('has_memory_for_regex') else 'no'} " + "recent_activity=" + f"{'yes' if novelty.get('has_recent_regex_activity') else 'no'}" + ), + ( + "recent_related: count=" + f"{recent_related.get('count', 'n/a')} saturation=" + f"{format_float(recent_related.get('saturation'))} score=" + f"{format_float(recent_related.get('score'))}" + ), + ( + "recent_pressure: combined=" + f"{format_float(recent_pressure.get('combined_score'))} pamp=" + f"{format_float(recent_pressure.get('pamp_score'))} damp=" + f"{format_float(recent_pressure.get('damp_score'))} " + "raw_pamp=" + f"{format_float(recent_pressure.get('pamp_total_raw'))} raw_damp=" + f"{format_float(recent_pressure.get('damp_total_raw'))}" + ), + ( + "previous_pressure: combined=" + f"{format_float(previous_pressure.get('combined_score'))} pamp=" + f"{format_float(previous_pressure.get('pamp_score'))} damp=" + f"{format_float(previous_pressure.get('damp_score'))} " + "raw_pamp=" + f"{format_float(previous_pressure.get('pamp_total_raw'))} raw_damp=" + f"{format_float(previous_pressure.get('damp_total_raw'))}" + ), + f"trend_ratio={format_float(components.get('trend_ratio'))}", + f"decrease_score={format_float(components.get('decrease_score'))}", + f"familiarity_score={format_float(components.get('familiarity_score'))}", + f"stability_score={format_float(components.get('stability_score'))}", + ] + return (summarize_trace_formula(formula, stage), lines) + + return ("n/a", ["No computation formatter for this trace stage."]) + + +def build_transition_computation_lines(transition: dict) -> tuple[str, list[str]]: + scores = transition.get("scores") or {} + if not scores: + return ("no score snapshot", ["This transition stored no score payload."]) + lines = [f"{key}={format_float(value)}" for key, value in sorted(scores.items())] + return (summarize_lines(lines, fallback="score snapshot"), lines) + + +def build_transition_event( + transition: dict, observations_by_id: dict[int, dict] +) -> dict: + observation = observations_by_id.get(int(transition.get("observation_id") or 0), {}) + threshold_summary, threshold_lines = generic_threshold_result( + transition.get("scores") or {} + ) + computation_summary, computation_lines = build_transition_computation_lines( + transition + ) + evidence_lines = [describe_observation_row(observation)] + return { + "ts": transition.get("created_at"), + "wall": ts_to_iso(transition.get("created_at")), + "source": "State transition", + "step": transition.get("reason") or "transition", + "stage": "transition", + "state_path": ( + f"{state_label(transition.get('from_state'))} → " + f"{state_label(transition.get('to_state'))}" + ), + "evidence_id": transition.get("evidence_id") or "", + "threshold_summary": threshold_summary, + "threshold_lines": threshold_lines, + "considered_summary": summarize_lines(evidence_lines), + "considered_lines": evidence_lines, + "computation_summary": computation_summary, + "computation_lines": computation_lines, + "priority": 2, + } + + +def build_trace_event(entry: dict) -> dict: + threshold_summary, threshold_lines = build_trace_threshold_result(entry) + considered_summary, considered_lines = build_trace_considered_evidence(entry) + computation_summary, computation_lines = build_trace_computation_lines(entry) + current_evidence = entry.get("current_evidence") or {} + evidence_id = current_evidence.get("evidence_id") or "" + evidence_type = current_evidence.get("evidence_type") or "" + signal = current_evidence.get("signal") or "" + if evidence_type or signal: + evidence_label = f"{evidence_id} | {evidence_type} | {signal}".strip(" |") + else: + evidence_label = evidence_id or "n/a" + return { + "ts": entry.get("_ts"), + "wall": entry.get("ts") or ts_to_iso(entry.get("_ts")), + "source": "Decision trace", + "step": f"{entry.get('stage') or 'trace'}: {entry.get('action') or 'event'}", + "stage": entry.get("stage") or "trace", + "state_path": ( + f"{entry.get('from_state') or 'n/a'} → {entry.get('to_state') or 'n/a'}" + ), + "evidence_id": evidence_label, + "threshold_summary": threshold_summary, + "threshold_lines": threshold_lines, + "considered_summary": considered_summary, + "considered_lines": considered_lines, + "computation_summary": computation_summary, + "computation_lines": computation_lines, + "priority": 1, + } + + +def build_life_path( + transitions_for_cell: list[dict], current_state_label: str | None +) -> str: + ordered = sorted( + transitions_for_cell, + key=lambda item: (float(item.get("created_at") or 0.0), int(item.get("id") or 0)), + ) + states = [] + for transition in ordered: + from_label = state_label(transition.get("from_state")) + to_label = state_label(transition.get("to_state")) + if not states: + states.append(from_label) + if states[-1] != from_label: + states.append(from_label) + if states[-1] != to_label: + states.append(to_label) + if not states and current_state_label: + states = [current_state_label] + elif current_state_label and states[-1] != current_state_label: + states.append(current_state_label) + return " → ".join(states) if states else "no recorded state changes" + + +def build_cell_histories( + observations: list[dict], + cells: list[dict], + transitions: list[dict], + trace_rows: list[dict], +) -> list[dict]: + observations_by_id = { + int(observation["id"]): observation + for observation in observations + if observation.get("id") is not None + } + cells_by_key = { + str(cell.get("cell_key")): cell + for cell in cells + if cell.get("cell_key") + } + transitions_by_cell: dict[str, list[dict]] = defaultdict(list) + for transition in transitions: + cell_key = str(transition.get("cell_key") or "") + if cell_key: + transitions_by_cell[cell_key].append(transition) + + traces_by_cell: dict[str, list[dict]] = defaultdict(list) + for entry in trace_rows: + cell_key = trace_row_cell_key(entry) + if cell_key: + traces_by_cell[cell_key].append(entry) + + cell_keys = set(cells_by_key) | set(transitions_by_cell) | set(traces_by_cell) + histories = [] + for cell_key in sorted(cell_keys): + cell = cells_by_key.get(cell_key, {}) + cell_transitions = transitions_by_cell.get(cell_key, []) + cell_traces = traces_by_cell.get(cell_key, []) + + events = [build_trace_event(entry) for entry in cell_traces] + events.extend( + build_transition_event(transition, observations_by_id) + for transition in cell_transitions + ) + events.sort( + key=lambda item: ( + item.get("ts") is None, + float(item.get("ts") or 0.0), + int(item.get("priority") or 9), + item.get("step") or "", + ) + ) + + current_state_label = state_label(cell.get("state")) if cell else None + waiting_label = cell_waiting_label(cell) if cell else "" + first_ts_candidates = [ + float(item.get("ts")) + for item in events + if item.get("ts") is not None + ] + if cell.get("created_at") is not None: + first_ts_candidates.append(float(cell.get("created_at"))) + last_ts_candidates = [ + float(item.get("ts")) + for item in events + if item.get("ts") is not None + ] + if cell.get("updated_at") is not None: + last_ts_candidates.append(float(cell.get("updated_at"))) + first_seen = min(first_ts_candidates) if first_ts_candidates else None + last_seen = max(last_ts_candidates) if last_ts_candidates else None + current_state_display = current_state_label or "unknown" + if waiting_label: + current_state_display += f" ({waiting_label})" + + histories.append( + { + "cell_key": cell_key, + "responsible_ip": cell.get("responsible_ip") + or ( + cell_transitions[0].get("profile_ip") + if cell_transitions + else (cell_traces[0].get("responsible_ip") if cell_traces else "") + ), + "regex_type": cell.get("regex_type") + or ( + cell_transitions[0].get("regex_type") + if cell_transitions + else ((cell_traces[0].get("candidate") or {}).get("regex_type", "")) + ), + "antigen_value": cell.get("antigen_value") + or ( + cell_transitions[0].get("antigen_value") + if cell_transitions + else ((cell_traces[0].get("candidate") or {}).get("value", "")) + ), + "matched_value": cell.get("matched_value") + or ( + cell_transitions[-1].get("matched_value") + if cell_transitions + else ((cell_traces[-1].get("match") or {}).get("value", "")) + ), + "current_state": current_state_display, + "current_state_class": state_class(cell.get("state")) + if cell + else "state-unknown", + "waiting_label": waiting_label, + "life_path": build_life_path(cell_transitions, current_state_label), + "first_seen": ts_to_iso(first_seen), + "last_seen": ts_to_iso(last_seen), + "event_count": len(events), + "transition_count": len(cell_transitions), + "trace_count": len(cell_traces), + "events": events, + } + ) + + return histories + + def build_report_payload( run_output_dir: Path, max_observations: int = 200, @@ -631,7 +1219,9 @@ def build_report_payload( memories = db_records["memories"] log_data = load_log_entries(log_path, max_log_lines) trace_rows = load_trace_entries(trace_path) - config = load_yaml_config(metadata_path).get("t_cell", {}) + metadata = load_yaml_config(metadata_path) + config = metadata.get("t_cell", {}) + parameters = metadata.get("parameters", {}) transitions_by_observation: dict[int, list[dict]] = defaultdict(list) for transition in transitions: @@ -825,6 +1415,12 @@ def build_report_payload( } for entry in log_data["entries"][-max(1, max_log_lines) :] ] + cell_histories = build_cell_histories( + observations=observations, + cells=cells, + transitions=transitions, + trace_rows=trace_rows, + ) report = { "generated_at": now_iso(), @@ -843,11 +1439,29 @@ def build_report_payload( "log_verbosity": config.get("log_verbosity"), "decision_trace_mode": config.get("decision_trace_mode"), "related_lookback_seconds": config.get("related_lookback_seconds"), + "related_pamps_saturation": config.get("related_pamps_saturation"), + "danger_saturation": config.get("danger_saturation"), + "damp_danger_weight": config.get("damp_danger_weight"), "co_stimulation_threshold": config.get("co_stimulation_threshold"), + "co_stimulation_weights": normalize_costim_weights( + config.get("co_stimulation_weights") + ), + "novelty_window_seconds": config.get("novelty_window_seconds"), + "context_recent_window_seconds": config.get( + "context_recent_window_seconds" + ), "effector_threshold": config.get("effector_threshold"), + "effector_min_related_count": config.get( + "effector_min_related_count" + ), "memory_threshold": config.get("memory_threshold"), + "memory_trend_ratio_max": config.get("memory_trend_ratio_max"), + "memory_min_related_count": config.get("memory_min_related_count"), "anergy_ttl_seconds": config.get("anergy_ttl_seconds"), "effector_cooldown_seconds": config.get("effector_cooldown_seconds"), + "state_wait_timeout_seconds": coerce_time_window_width( + parameters.get("time_window_width") + ), }, "totals": { "observations": len(observations), @@ -893,6 +1507,7 @@ def build_report_payload( "rows": recent_trace_rows[: max(1, max_trace_rows)], "total_rows": len(trace_rows), }, + "cell_histories": cell_histories, "log": { "rows": recent_log_rows, "tail_text": "\n".join(log_data["tail"]), @@ -1430,6 +2045,723 @@ def render_pretty_json(value: Any) -> str: return escape(json.dumps(value, indent=2, sort_keys=True)) +def render_formula_box(lines: list[str]) -> str: + return ( + "
    "
    +        + escape("\n".join(lines))
    +        + "
    " + ) + + +def render_term_cards(terms: list[dict]) -> str: + return "".join( + f""" +
    +

    {escape(term['label'])}

    +

    {escape(term['formula'])}

    +

    {escape(term['description'])}

    +
    + """ + for term in terms + ) + + +def render_formula_tree_node(node: dict) -> str: + children = node.get("children") or [] + child_class = "formula-children" + if len(children) > 1: + child_class += " has-multiple" + tooltip = node.get("tooltip") or "" + formula = node.get("formula") or "" + summary = node.get("summary") or "" + children_html = "" + if children: + children_html = ( + f"
    " + + "".join( + "
    " + + render_formula_tree_node(child) + + "
    " + for child in children + ) + + "
    " + ) + return f""" +
    +
    + {escape(node.get('label', 'value'))} + {f"{escape(formula)}" if formula else ""} + {f"{escape(summary)}" if summary else ""} + {f"{escape(tooltip)}" if tooltip else ""} +
    + {children_html} +
    + """ + + +def render_formula_tree(node: dict) -> str: + return f"
    {render_formula_tree_node(node)}
    " + + +def render_decision_doc_card(card: dict) -> str: + equation_html = render_formula_box(card["equation_lines"]) + gate_html = render_formula_box(card["gate_lines"]) + term_cards_html = render_term_cards(card["terms"]) + tree_html = render_formula_tree(card["tree"]) + notes_html = "".join( + f"

    {escape(note)}

    " + for note in card.get("notes", []) + ) + return f""" +
    +
    +

    {escape(card['title'])}

    +

    {escape(card['summary'])}

    +
    +
    +
    +

    Exact Equation

    + {equation_html} +
    +
    +

    Decision Gate

    + {gate_html} +
    +
    + {notes_html} +
    + {term_cards_html} +
    +
    +
    +

    Input Tree

    +

    Hover or focus a node to see where that term comes from.

    +
    + {tree_html} +
    +
    + """ + + +def render_rule_cards(cards: list[dict]) -> str: + return "".join( + f""" +
    +

    {escape(card['title'])}

    +

    {escape(card['rule'])}

    +

    {escape(card['description'])}

    +
    + """ + for card in cards + ) + + +def render_decision_reference(report: dict) -> str: + config = report_config_with_defaults(report) + weights = config["co_stimulation_weights"] + related_lookback = format_float(config["related_lookback_seconds"]) + related_saturation = format_float(config["related_pamps_saturation"]) + danger_saturation = format_float(config["danger_saturation"]) + damp_weight = format_float(config["damp_danger_weight"]) + novelty_window = format_float(config["novelty_window_seconds"]) + context_window = format_float(config["context_recent_window_seconds"]) + wait_limit = format_float(config["state_wait_timeout_seconds"]) + co_threshold = format_float(config["co_stimulation_threshold"]) + effector_threshold = format_float(config["effector_threshold"]) + effector_min_related = str(int(config["effector_min_related_count"])) + effector_cooldown = format_float(config["effector_cooldown_seconds"]) + memory_threshold = format_float(config["memory_threshold"]) + memory_ratio_max = format_float(config["memory_trend_ratio_max"]) + memory_min_related = str(int(config["memory_min_related_count"])) + anergy_ttl = format_float(config["anergy_ttl_seconds"]) + + decision_cards = [ + { + "title": "Co-Stimulation: 1 -> 3 activation", + "summary": "This score is evaluated after antigen recognition to decide whether the cell activates.", + "equation_lines": [ + ( + "co_stimulation = " + f"{format_float(weights['confidence'])} * confidence" + ), + ( + f" + {format_float(weights['related_pamps'])} " + "* related_pamp_score" + ), + ( + f" + {format_float(weights['danger'])} " + "* profile_danger_score" + ), + ], + "gate_lines": [ + f"activate when co_stimulation >= {co_threshold}", + "otherwise stay in 1 - antigen-recognized", + f"timeout to 2 - anergic after {wait_limit}s if still below threshold", + ], + "notes": [ + f"Related PAMPs are counted over the last {related_lookback}s for the same responsible IP.", + "A related PAMP shares either the same antigen value or the same matched regex hash. The current observation is excluded from that count.", + "DAMP observations never create cells, but they do raise the mixed danger term used here.", + ], + "terms": [ + { + "label": "confidence", + "formula": "current evidence.confidence", + "description": "The confidence carried by the observation that is being evaluated right now.", + }, + { + "label": "related_pamp_score", + "formula": f"clamp01(related_pamp_count / {related_saturation})", + "description": "How much recent, related PAMP evidence reinforces the same antigen or regex identity.", + }, + { + "label": "profile_danger_score", + "formula": ( + "clamp01((pamp_raw + " + f"{damp_weight} * damp_raw) / {danger_saturation})" + ), + "description": "The mixed danger pressure for the same responsible IP, with DAMP raw danger amplified before normalization.", + }, + { + "label": "pamp_raw / damp_raw", + "formula": "sum(threat_level_value * confidence)", + "description": "Raw danger is the sum of threat level value multiplied by confidence across recent PAMP or DAMP observations.", + }, + ], + "tree": { + "label": "co_stimulation", + "formula": ( + f"{format_float(weights['confidence'])} * confidence + " + f"{format_float(weights['related_pamps'])} * related_pamp_score + " + f"{format_float(weights['danger'])} * profile_danger_score" + ), + "summary": f"Activation score. Threshold = {co_threshold}", + "tooltip": "Final co-stimulation score used for the 1 -> 3 decision.", + "children": [ + { + "label": "confidence", + "formula": "current evidence.confidence", + "summary": "Current PAMP confidence", + "tooltip": "Read directly from the observation currently being processed.", + }, + { + "label": "related_pamp_score", + "formula": f"clamp01(related_pamp_count / {related_saturation})", + "summary": "Recent related PAMP reinforcement", + "tooltip": "Normalized count of related PAMP observations in the related lookback window.", + "children": [ + { + "label": "related_pamp_count", + "formula": "count of related recent PAMPs", + "summary": "Same antigen value or same matched regex hash", + "tooltip": ( + f"Counted over the last {related_lookback}s for the same responsible IP. " + "The current observation is excluded." + ), + }, + { + "label": "related_pamps_saturation", + "formula": related_saturation, + "summary": "Count where the score saturates at 1", + "tooltip": "If the count reaches this value, related_pamp_score stops increasing.", + }, + ], + }, + { + "label": "profile_danger_score", + "formula": ( + "clamp01((pamp_raw + " + f"{damp_weight} * damp_raw) / {danger_saturation})" + ), + "summary": "Normalized mixed danger for the responsible IP", + "tooltip": "Recent PAMP and DAMP danger are combined, then clamped into the 0..1 range.", + "children": [ + { + "label": "pamp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent PAMP raw danger", + "tooltip": ( + f"Summed over PAMP observations for the same responsible IP within the last {related_lookback}s." + ), + }, + { + "label": "damp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent DAMP raw danger", + "tooltip": ( + f"Summed over DAMP observations for the same responsible IP within the last {related_lookback}s." + ), + }, + { + "label": "damp_danger_weight", + "formula": damp_weight, + "summary": "Amplifies DAMP raw danger before normalization", + "tooltip": "DAMP pressure is scaled before it is added into the mixed danger term.", + }, + { + "label": "danger_saturation", + "formula": danger_saturation, + "summary": "Raw danger amount that maps to score 1", + "tooltip": "The combined raw danger is divided by this value before clamp01 is applied.", + }, + ], + }, + ], + }, + }, + { + "title": "Context Effector: 3 -> 4 containment", + "summary": "This score evaluates whether an activated cell should escalate into an effector response.", + "equation_lines": [ + "effector_score = 0.45 * recent_pressure", + " + 0.25 * recent_related_score", + " + 0.30 * novelty_score", + ], + "gate_lines": [ + "effector = (novelty_score > 0)", + f" and (recent_related_count >= {effector_min_related})", + f" and (effector_score >= {effector_threshold})", + ], + "notes": [ + f"recent_pressure is computed over the last {context_window}s and uses the same mixed PAMP + weighted DAMP danger model as co-stimulation.", + f"novelty_score is binary: it becomes 1 only if the matched regex has no stored memory row and no recent transition activity in the last {novelty_window}s.", + f"If the cell reaches state 4, repeated containment is still gated by an effector cooldown of {effector_cooldown}s.", + ], + "terms": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "description": "The normalized mixed danger in the recent context window for the same responsible IP.", + }, + { + "label": "recent_related_score", + "formula": f"clamp01(recent_related_count / {related_saturation})", + "description": "How much recent PAMP evidence in the context window still points to the same antigen or regex identity.", + }, + { + "label": "novelty_score", + "formula": "1 if no memory and no recent regex activity else 0", + "description": "A binary novelty gate. If the regex is already familiar, the effector path is blocked immediately.", + }, + ], + "tree": { + "label": "effector_score", + "formula": "0.45 * recent_pressure + 0.25 * recent_related_score + 0.30 * novelty_score", + "summary": f"Containment score. Threshold = {effector_threshold}", + "tooltip": "Final context score used to decide whether state 3 escalates to state 4.", + "children": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger during the most recent {context_window}s window", + "tooltip": "Computed from the recent context window immediately before the current decision.", + "children": [ + { + "label": "recent_pamp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent PAMP raw danger", + "tooltip": "Summed over recent PAMP observations in the context window.", + }, + { + "label": "recent_damp_raw", + "formula": "sum(threat_level_value * confidence)", + "summary": "Recent DAMP raw danger", + "tooltip": "Summed over recent DAMP observations in the context window.", + }, + ], + }, + { + "label": "recent_related_score", + "formula": f"clamp01(recent_related_count / {related_saturation})", + "summary": "Recent supporting PAMP count normalized to 0..1", + "tooltip": "Counts related PAMP observations in the recent context window.", + "children": [ + { + "label": "recent_related_count", + "formula": "count of related recent PAMPs", + "summary": "Same antigen value or same matched regex hash", + "tooltip": ( + f"Counted only inside the recent context window of {context_window}s." + ), + }, + { + "label": "related_pamps_saturation", + "formula": related_saturation, + "summary": "Cap for the normalized related score", + "tooltip": "The count is divided by this saturation value before clamp01 is applied.", + }, + ], + }, + { + "label": "novelty_score", + "formula": "1 if no memory and no recent activity else 0", + "summary": "Binary novelty gate", + "tooltip": "Effector requires the regex to still look new for this responsible IP.", + "children": [ + { + "label": "has_memory_for_regex", + "formula": "memory row exists for regex_hash", + "summary": "If true, novelty_score becomes 0", + "tooltip": "A stored memory for the regex marks it as familiar immediately.", + }, + { + "label": "has_recent_regex_activity", + "formula": f"transition activity within {novelty_window}s", + "summary": "If true, novelty_score becomes 0", + "tooltip": "Any recent transition for the same responsible IP and regex hash removes novelty.", + }, + ], + }, + ], + }, + }, + { + "title": "Context Memory: 3 -> 5 storage", + "summary": "This score evaluates whether an activated cell should store memory instead of escalating to containment.", + "equation_lines": [ + "memory_score = 0.60 * decrease_score", + " + 0.25 * familiarity_score", + " + 0.15 * stability_score", + ], + "gate_lines": [ + "memory = (familiarity_score > 0)", + f" and (recent_related_count >= {memory_min_related})", + f" and (trend_ratio <= {memory_ratio_max})", + f" and (memory_score >= {memory_threshold})", + ], + "notes": [ + "Memory is the cooling-down path: the same pattern is no longer novel, pressure is lower than before, and enough related evidence still supports the match.", + "trend_ratio compares the recent mixed pressure window against the previous adjacent window. Lower is better for memory.", + f"If neither effector nor memory passes, the cell stays in 3 - activated until the context wait timeout of {wait_limit}s expires.", + ], + "terms": [ + { + "label": "decrease_score", + "formula": "clamp01(1 - trend_ratio)", + "description": "Rewards situations where recent pressure is clearly lower than previous pressure.", + }, + { + "label": "trend_ratio", + "formula": "recent_pressure / max(previous_pressure, 0.01)", + "description": "Measures whether the mixed danger is falling, flat, or rising between adjacent context windows.", + }, + { + "label": "familiarity_score", + "formula": "1 - novelty_score", + "description": "Memory requires the regex to already be familiar rather than novel.", + }, + { + "label": "stability_score", + "formula": f"clamp01(recent_related_count / {memory_min_related})", + "description": "Ensures there is still enough related recent evidence to justify storing memory.", + }, + ], + "tree": { + "label": "memory_score", + "formula": "0.60 * decrease_score + 0.25 * familiarity_score + 0.15 * stability_score", + "summary": f"Memory score. Threshold = {memory_threshold}", + "tooltip": "Final context score used to decide whether state 3 transitions into state 5.", + "children": [ + { + "label": "decrease_score", + "formula": "clamp01(1 - trend_ratio)", + "summary": "Higher when recent pressure is falling", + "tooltip": "A falling trend pushes the memory score up.", + "children": [ + { + "label": "trend_ratio", + "formula": "recent_pressure / max(previous_pressure, 0.01)", + "summary": f"Must stay <= {memory_ratio_max} for memory", + "tooltip": "Compares the most recent context window against the immediately preceding one.", + "children": [ + { + "label": "recent_pressure", + "formula": ( + "clamp01((recent_pamp_raw + " + f"{damp_weight} * recent_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger over the last {context_window}s", + "tooltip": "Same recent pressure value also used by effector_score.", + }, + { + "label": "previous_pressure", + "formula": ( + "clamp01((previous_pamp_raw + " + f"{damp_weight} * previous_damp_raw) / {danger_saturation})" + ), + "summary": f"Mixed danger over the previous {context_window}s window", + "tooltip": "Computed over the context window immediately before the recent one.", + }, + ], + } + ], + }, + { + "label": "familiarity_score", + "formula": "1 - novelty_score", + "summary": "Higher when the regex is already familiar", + "tooltip": "Memory is only allowed once novelty has disappeared.", + "children": [ + { + "label": "novelty_score", + "formula": "1 if no memory and no recent activity else 0", + "summary": "Same novelty gate used by the effector path", + "tooltip": "If novelty_score stays 1, familiarity_score stays 0 and memory fails.", + } + ], + }, + { + "label": "stability_score", + "formula": f"clamp01(recent_related_count / {memory_min_related})", + "summary": "Recent evidence stability", + "tooltip": "Memory still requires enough related recent PAMPs to support the pattern.", + "children": [ + { + "label": "recent_related_count", + "formula": "count of related recent PAMPs", + "summary": f"Must stay >= {memory_min_related}", + "tooltip": "Related means same antigen value or same matched regex hash in the recent context window.", + } + ], + }, + ], + }, + }, + ] + + rule_cards = [ + { + "title": "Recognition", + "rule": "0 -> 1 when a PAMP has an extracted antigen and an accepted regex match", + "description": "If a PAMP has antigens but no accepted regex match, the cell instead goes 0 -> 2 and becomes anergic.", + }, + { + "title": "Anergy Expiry", + "rule": f"2 -> 0 when anergy_ttl_seconds ({anergy_ttl}s) has elapsed", + "description": "Once the anergy TTL expires, the cell returns to mature and can be evaluated again.", + }, + { + "title": "Co-Stimulation Timeout", + "rule": f"1 -> 2 when the co-stimulation wait reaches {wait_limit}s", + "description": "The cell can keep waiting in 1 - antigen-recognized while later PAMP or DAMP evidence reevaluates the score, but only for one configured Slips time window.", + }, + { + "title": "Context Timeout", + "rule": f"3 -> 0 when the context wait reaches {wait_limit}s", + "description": "If neither effector nor memory passes before the waiting window ends, the cell falls back to 0 - mature.", + }, + { + "title": "Effector Cooldown", + "rule": f"4 -> 4 suppress repeated containment until {effector_cooldown}s passes", + "description": "The state can stay effector while repeated blocking publications are suppressed by cooldown.", + }, + { + "title": "Memory Retention", + "rule": "5 -> 5 keep the memory state on later matching evidence", + "description": "Once memory is stored for that cell, later hits retain state 5 without writing repeated memory_stored events.", + }, + ] + + decision_cards_html = "".join( + render_decision_doc_card(card) for card in decision_cards + ) + rule_cards_html = render_rule_cards(rule_cards) + return f""" +
    +
    +

    Decision Reference

    +

    Bottom-of-report explanation of how the T Cell equations and branch conditions are computed.

    +
    +

    + This section documents the exact values, thresholds, and helper rules used by the report and by the T Cell module decision logic. + Normalization uses clamp01(x) = max(0, min(1, x)). Hover or focus a node in each tree to inspect where that term comes from. +

    +
    + {decision_cards_html} +
    +
    +
    +

    Rule-Based Decisions

    +

    These branches are not weighted equations, but they still change state or suppress actions.

    +
    +
    + {rule_cards_html} +
    +
    +
    + """ + + +def render_history_details(summary: str, lines: list[str]) -> str: + details_body = escape("\n".join(lines or ["n/a"])) + return ( + f"
    {escape(summary)}" + f"
    {details_body}
    " + ) + + +def render_history_event_table(events: list[dict]) -> str: + if not events: + return '

    No history events were recorded for this T cell.

    ' + + head = "".join( + f"{escape(column)}" + for column in [ + "When", + "Source", + "Step", + "State path", + "Evidence", + "Threshold result", + "Evidence considered", + "Computation", + ] + ) + rows = [] + for event in events: + row_cells = [ + escape(event.get("wall") or "n/a"), + escape(event.get("source") or "unknown"), + escape(event.get("step") or "event"), + escape(event.get("state_path") or "n/a"), + escape(event.get("evidence_id") or "n/a"), + render_history_details( + event.get("threshold_summary") or "n/a", + event.get("threshold_lines") or [], + ), + render_history_details( + event.get("considered_summary") or "n/a", + event.get("considered_lines") or [], + ), + render_history_details( + event.get("computation_summary") or "n/a", + event.get("computation_lines") or [], + ), + ] + rows.append( + "" + + "".join(f"{cell}" for cell in row_cells) + + "" + ) + return ( + "
    " + "" + f"{head}" + f"{''.join(rows)}
    " + ) + + +def render_cell_histories(report: dict) -> str: + histories = report.get("cell_histories") or [] + if not histories: + return """ +
    +

    T Cell Histories

    +

    No T-cell histories were available for this run.

    +
    + """ + + index_rows = [ + { + "Responsible": escape(item.get("responsible_ip") or ""), + "Cell": escape(shorten(item.get("cell_key") or "", 64)), + "Current state": render_badge( + item.get("current_state") or "unknown", + item.get("current_state_class") or "state-unknown", + ), + "Life path": escape(shorten(item.get("life_path") or "", 88)), + "Events": escape(str(item.get("event_count") or 0)), + } + for item in histories + ] + index_table = render_simple_table( + ["Responsible", "Cell", "Current state", "Life path", "Events"], + index_rows, + "No T-cell history index available.", + ) + + trace_mode = (report.get("config") or {}).get("decision_trace_mode") + if trace_mode in (None, "", {}): + trace_note = ( + "Decision trace configuration was not found in metadata, so histories rely on whatever trace rows and transitions were stored." + ) + elif str(trace_mode).lower() in {"0", "off"}: + trace_note = ( + "Decision trace was off for this run, so histories can only show state transitions and any score snapshots saved with those transitions." + ) + elif str(trace_mode).lower() in {"1", "transitions"}: + trace_note = ( + "Decision trace was limited to transition events, so waiting reevaluations may be missing from the lifecycle view." + ) + else: + trace_note = ( + "Decision trace was fully enabled, so histories include both state changes and intermediate decision evaluations when available." + ) + + history_cards = [] + for index, item in enumerate(histories): + title = ( + f"{item.get('responsible_ip') or 'unknown'} | " + f"{item.get('regex_type') or 'unknown'}:{item.get('antigen_value') or ''}" + ) + meta_bits = [ + f"current={item.get('current_state') or 'unknown'}", + f"life path={item.get('life_path') or 'n/a'}", + f"first seen={item.get('first_seen') or 'n/a'}", + f"last seen={item.get('last_seen') or 'n/a'}", + f"events={item.get('event_count') or 0}", + f"transitions={item.get('transition_count') or 0}", + f"trace rows={item.get('trace_count') or 0}", + ] + table_html = render_history_event_table(item.get("events") or []) + history_cards.append( + f""" +
    + +
    +
    +

    T Cell

    +

    {escape(title)}

    +

    {escape(' | '.join(meta_bits))}

    +
    +
    + {render_badge(item.get("current_state") or "unknown", item.get("current_state_class") or "state-unknown")} +
    +
    +
    +
    +

    Cell key: {escape(item.get('cell_key') or '')}

    +

    Matched value: {escape(item.get('matched_value') or 'n/a')}

    + {table_html} +
    +
    + """ + ) + + return f""" +
    +
    +

    T Cell Histories

    +

    Chronological lifecycle view for each cell, combining stored state transitions with decision-trace computations.

    +
    +

    {escape(trace_note)}

    +
    +

    History Index

    + {index_table} +
    +
    + {''.join(history_cards)} +
    +
    + """ + + def render_html(report: dict) -> str: findings_html = "".join( f"
  • {escape(item)}
  • " for item in report.get("findings", []) @@ -1633,6 +2965,8 @@ def render_html(report: dict) -> str: TRACE_STAGE_COLORS, ) state_machine_graph = render_state_machine_graph(report) + decision_reference = render_decision_reference(report) + histories_section = render_cell_histories(report) return f""" @@ -1699,13 +3033,52 @@ def render_html(report: dict) -> str: font-size: 0.80rem; word-break: break-all; }} - .summary-grid, .panel-grid, .stats-grid {{ - display: grid; - gap: 14px; - }} - .stats-grid {{ - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - }} + .summary-grid, .panel-grid, .stats-grid {{ + display: grid; + gap: 14px; + }} + .tab-strip {{ + display: inline-flex; + gap: 8px; + margin: 16px 0 4px; + padding: 6px; + border-radius: 999px; + background: rgba(255, 253, 248, 0.82); + border: 1px solid rgba(123, 83, 44, 0.12); + box-shadow: 0 12px 26px rgba(66, 43, 17, 0.07); + position: sticky; + top: 10px; + z-index: 8; + backdrop-filter: blur(8px); + }} + .tab-button {{ + border: 0; + border-radius: 999px; + padding: 10px 16px; + background: transparent; + color: var(--muted); + font: inherit; + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; + }} + .tab-button:hover {{ + color: #7c2d12; + }} + .tab-button.is-active {{ + background: linear-gradient(180deg, rgba(180, 83, 9, 0.12), rgba(180, 83, 9, 0.18)); + color: #7c2d12; + box-shadow: inset 0 0 0 1px rgba(180, 83, 9, 0.12); + }} + .report-tab-panel {{ + display: none; + }} + .report-tab-panel.is-active {{ + display: block; + }} + .stats-grid {{ + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + }} .panel-grid {{ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-top: 14px; @@ -1941,23 +3314,300 @@ def render_html(report: dict) -> str: .footer-panel .report-table {{ min-width: 480px; }} - .footer-panel .report-table th, - .footer-panel .report-table td {{ - font-size: 0.70rem; - padding: 5px 7px; - }} - @media (max-width: 900px) {{ - body {{ font-size: 13px; }} - main {{ padding: 16px 12px 40px; }} - .panel-grid {{ grid-template-columns: 1fr; }} - .report-table {{ min-width: 680px; }} - }} - + .footer-panel .report-table th, + .footer-panel .report-table td {{ + font-size: 0.70rem; + padding: 5px 7px; + }} + .decision-reference, + .decision-doc, + .tree-block {{ + overflow: visible; + }} + .decision-reference code, + .formula-box code, + .term-formula, + .formula-node-formula {{ + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + }} + .decision-lead {{ + margin: 0 0 14px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.55; + }} + .decision-doc-grid {{ + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + }} + .decision-doc {{ + border: 1px solid rgba(123, 83, 44, 0.12); + border-radius: 18px; + background: linear-gradient(180deg, rgba(255, 253, 248, 0.96), rgba(245, 237, 224, 0.96)); + padding: 14px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); + }} + .decision-doc .panel-head {{ + align-items: flex-start; + margin-bottom: 12px; + }} + .decision-doc .panel-head h3, + .tree-block .panel-head h4 {{ + margin: 0; + }} + .equation-grid {{ + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + }} + .formula-box {{ + margin: 0; + padding: 12px; + border-radius: 14px; + border: 1px solid rgba(180, 83, 9, 0.16); + background: linear-gradient(180deg, rgba(255, 250, 240, 0.98), rgba(252, 242, 227, 0.98)); + color: #6b3f07; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); + overflow: auto; + white-space: pre-wrap; + }} + .decision-note {{ + margin: 10px 0 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.5; + }} + .term-grid, + .rule-grid {{ + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-top: 12px; + }} + .term-card, + .rule-card {{ + background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + }} + .term-formula {{ + margin: 0 0 8px; + font-size: 0.74rem; + line-height: 1.5; + color: #92400e; + overflow-wrap: anywhere; + }} + .term-body {{ + margin: 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.52; + }} + .tree-block {{ + margin-top: 14px; + padding: 12px; + border-radius: 16px; + border: 1px solid rgba(123, 83, 44, 0.12); + background: rgba(255, 253, 248, 0.74); + }} + .formula-tree {{ + overflow: auto; + padding: 96px 6px 6px; + }} + .formula-node-wrap {{ + display: flex; + flex-direction: column; + align-items: center; + min-width: max-content; + position: relative; + }} + .formula-node {{ + position: relative; + display: grid; + gap: 4px; + min-width: 180px; + max-width: 260px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(123, 83, 44, 0.16); + background: linear-gradient(180deg, rgba(255, 253, 248, 0.99), rgba(248, 239, 226, 0.98)); + box-shadow: 0 12px 24px rgba(66, 43, 17, 0.08); + outline: none; + }} + .formula-node:hover, + .formula-node:focus {{ + border-color: rgba(180, 83, 9, 0.72); + box-shadow: 0 16px 28px rgba(180, 83, 9, 0.12); + }} + .formula-node-label {{ + font-weight: 700; + font-size: 0.82rem; + color: var(--ink); + }} + .formula-node-formula {{ + font-size: 0.71rem; + line-height: 1.45; + color: #92400e; + }} + .formula-node-summary {{ + font-size: 0.74rem; + line-height: 1.4; + color: var(--muted); + }} + .formula-tooltip {{ + position: absolute; + left: 50%; + bottom: calc(100% + 12px); + transform: translateX(-50%) translateY(6px); + min-width: 230px; + max-width: 320px; + padding: 10px 12px; + border-radius: 12px; + background: #1f2937; + color: #f8fafc; + font-size: 0.72rem; + line-height: 1.45; + box-shadow: 0 18px 28px rgba(15, 23, 42, 0.28); + opacity: 0; + pointer-events: none; + transition: opacity 140ms ease, transform 140ms ease; + z-index: 25; + }} + .formula-tooltip::after {{ + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 6px; + border-style: solid; + border-color: #1f2937 transparent transparent transparent; + }} + .formula-node:hover .formula-tooltip, + .formula-node:focus .formula-tooltip, + .formula-node:focus-within .formula-tooltip {{ + opacity: 1; + transform: translateX(-50%) translateY(0); + }} + .formula-children {{ + display: flex; + justify-content: center; + gap: 16px; + align-items: flex-start; + position: relative; + padding-top: 18px; + margin-top: 10px; + }} + .formula-children::before {{ + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 18px; + background: var(--line); + }} + .formula-children.has-multiple::after {{ + content: ""; + position: absolute; + top: 0; + left: 12%; + right: 12%; + height: 2px; + background: var(--line); + }} + .formula-branch {{ + position: relative; + display: flex; + flex-direction: column; + align-items: center; + }} + .formula-branch::before {{ + content: ""; + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 18px; + background: var(--line); + }} + .history-index-panel {{ + margin-top: 14px; + }} + .history-stack {{ + display: grid; + gap: 12px; + margin-top: 14px; + }} + .history-card {{ + border: 1px solid rgba(123, 83, 44, 0.12); + border-radius: 18px; + background: rgba(255, 253, 248, 0.92); + overflow: hidden; + box-shadow: 0 14px 24px rgba(66, 43, 17, 0.06); + }} + .history-card summary {{ + list-style: none; + cursor: pointer; + padding: 14px 16px; + }} + .history-card summary::-webkit-details-marker {{ + display: none; + }} + .history-card[open] summary {{ + border-bottom: 1px solid rgba(123, 83, 44, 0.12); + background: linear-gradient(180deg, rgba(245, 237, 224, 0.96), rgba(255, 253, 248, 0.96)); + }} + .history-summary {{ + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + }} + .history-summary h3 {{ + margin: 0 0 6px; + font-size: 0.94rem; + line-height: 1.35; + }} + .history-summary-side {{ + flex-shrink: 0; + }} + .history-body {{ + padding: 14px 16px 16px; + }} + .history-table {{ + min-width: 1120px; + table-layout: auto; + }} + .history-table td {{ + min-width: 120px; + }} + @media (max-width: 900px) {{ + body {{ font-size: 13px; }} + main {{ padding: 16px 12px 40px; }} + .panel-grid {{ grid-template-columns: 1fr; }} + .report-table {{ min-width: 680px; }} + .decision-doc-grid {{ grid-template-columns: 1fr; }} + .formula-tree {{ padding-top: 104px; }} + .tab-strip {{ + width: 100%; + justify-content: stretch; + }} + .tab-button {{ + flex: 1 1 0; + text-align: center; + }} + }} +
    -
    -

    T Cell HTML Report

    +
    +

    T Cell HTML Report

    T Cell Run Report

    Static analysis of observations, signals, transitions, memories, and optional decision traces. Generated at {escape(report['generated_at'])}

    @@ -1967,11 +3617,18 @@ def render_html(report: dict) -> str:

    Database

    {escape(report['sources']['db_path'])}

    Module Log

    {escape(report['sources']['log_path'])}

    Decision Trace

    {escape(report['sources']['trace_path'])}
    -
    -
    + +
    -
    -
    +
    + + +
    + +
    + +
    +

    Quick Summary

    {render_counter_cards(report)} @@ -2030,27 +3687,55 @@ def render_html(report: dict) -> str: {trace_section}
    -
    -

    Recent Observations

    -

    These rows come from the T Cell SQLite DB, so they remain available even when module log verbosity was low. Click a column header to sort.

    - {observation_table} -
    - - -
    -