From 879465b665c9f68f8d4409aefc0095bedf624749 Mon Sep 17 00:00:00 2001 From: "SEGUY Remi (DIGIT)" Date: Thu, 25 Jun 2026 08:40:04 +0200 Subject: [PATCH 1/2] feat: Add MISP Sharing Pipeline engine --- .github/workflows/sharing.yml | 65 + Engines/modules/sharing.py | 462 ++++++ Engines/requirements.txt | 4 +- Engines/sharing/__init__.py | 7 + Engines/sharing/connector.py | 223 +++ Engines/sharing/events.py | 688 ++++++++ Engines/sharing/relations.py | 192 +++ Engines/sharing/scope.py | 135 ++ Engines/sharing/tagging.py | 438 +++++ Engines/sharing/tests/__init__.py | 1 + .../tests/test_config_parsing_properties.py | 919 +++++++++++ Engines/sharing/tests/test_connector.py | 886 ++++++++++ Engines/sharing/tests/test_events.py | 1454 +++++++++++++++++ .../sharing/tests/test_events_lifecycle.py | 989 +++++++++++ .../test_misp_object_completeness_property.py | 673 ++++++++ .../sharing/tests/test_relations_property.py | 530 ++++++ .../tests/test_tlp_hierarchy_property.py | 357 ++++ .../test_tlp_scope_filtering_property.py | 880 ++++++++++ .../tests/test_tvm_chaining_property.py | 639 ++++++++ Engines/sharing/tests/test_uuid_derivation.py | 309 ++++ .../tests/test_version_comparison_property.py | 499 ++++++ Orchestration/share.py | 474 ++++++ Pipelines/GitHub/sharing/action.yml | 29 + tests/__init__.py | 1 + tests/conftest.py | 45 + tests/sharing/__init__.py | 1 + tests/sharing/test_integration.py | 1047 ++++++++++++ tests/sharing/test_orchestrator_flow.py | 971 +++++++++++ tests/sharing/test_relation_properties.py | 683 ++++++++ tests/sharing/test_tagging_properties.py | 1091 +++++++++++++ tests/sharing/test_tlp_properties.py | 488 ++++++ 31 files changed, 15179 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sharing.yml create mode 100644 Engines/modules/sharing.py create mode 100644 Engines/sharing/__init__.py create mode 100644 Engines/sharing/connector.py create mode 100644 Engines/sharing/events.py create mode 100644 Engines/sharing/relations.py create mode 100644 Engines/sharing/scope.py create mode 100644 Engines/sharing/tagging.py create mode 100644 Engines/sharing/tests/__init__.py create mode 100644 Engines/sharing/tests/test_config_parsing_properties.py create mode 100644 Engines/sharing/tests/test_connector.py create mode 100644 Engines/sharing/tests/test_events.py create mode 100644 Engines/sharing/tests/test_events_lifecycle.py create mode 100644 Engines/sharing/tests/test_misp_object_completeness_property.py create mode 100644 Engines/sharing/tests/test_relations_property.py create mode 100644 Engines/sharing/tests/test_tlp_hierarchy_property.py create mode 100644 Engines/sharing/tests/test_tlp_scope_filtering_property.py create mode 100644 Engines/sharing/tests/test_tvm_chaining_property.py create mode 100644 Engines/sharing/tests/test_uuid_derivation.py create mode 100644 Engines/sharing/tests/test_version_comparison_property.py create mode 100644 Orchestration/share.py create mode 100644 Pipelines/GitHub/sharing/action.yml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/sharing/__init__.py create mode 100644 tests/sharing/test_integration.py create mode 100644 tests/sharing/test_orchestrator_flow.py create mode 100644 tests/sharing/test_relation_properties.py create mode 100644 tests/sharing/test_tagging_properties.py create mode 100644 tests/sharing/test_tlp_properties.py diff --git a/.github/workflows/sharing.yml b/.github/workflows/sharing.yml new file mode 100644 index 00000000..4f6ceadf --- /dev/null +++ b/.github/workflows/sharing.yml @@ -0,0 +1,65 @@ +name: MISP Sharing Pipeline + +on: + workflow_call: + inputs: + coretide_repo: + type: string + description: "GitHub repository (owner/name) to clone CoreTide from" + required: false + default: OpenTideHQ/CoreTide + coretide_path: + type: string + description: "Path where CoreTide needs to be cloned into" + required: false + default: coretide + coretide_ref: + type: string + description: "Ref to clone CoreTide from" + required: false + default: development + python_version: + type: string + description: "Python version to use" + required: false + default: "3.12" + requirements_file: + type: string + description: "Requirements file path" + required: false + default: "coretide/Engines/requirements.txt" + opentide_secrets: + type: string + description: "Content of the OpenTide secrets file" + required: false + +jobs: + Sharing: + name: MISP Sharing + runs-on: ubuntu-latest + if: ${{ github.ref_name == github.event.repository.default_branch }} + steps: + - name: Checkout Tide Instance + uses: actions/checkout@v4 + with: + fetch-depth: 2 + ref: ${{ github.ref }} + + - name: Checkout CoreTide + uses: actions/checkout@v4 + with: + repository: ${{ inputs.coretide_repo }} + path: ${{ inputs.coretide_path }} + ref: refs/heads/${{ inputs.coretide_ref }} + + - name: Tide Setup + uses: ./coretide/Pipelines/GitHub/tide-setup + id: tide_setup + with: + python_version: ${{ inputs.python_version }} + requirements_file: ${{ inputs.requirements_file }} + opentide_secrets: ${{ inputs.opentide_secrets }} + + - uses: ./coretide/Pipelines/GitHub/sharing + name: MISP Sharing + id: misp_sharing diff --git a/Engines/modules/sharing.py b/Engines/modules/sharing.py new file mode 100644 index 00000000..4116ddc7 --- /dev/null +++ b/Engines/modules/sharing.py @@ -0,0 +1,462 @@ +"""Engines/modules/sharing.py — Sharing configuration and helpers. + +This module provides configuration dataclasses, TLP handling, and utility functions +for the MISP sharing pipeline. It follows the CoreTIDE module patterns established +in other Engines/modules/ files. +""" + +import re +import uuid +from dataclasses import dataclass, field, asdict +from enum import IntEnum +from typing import Any, Dict, List, Literal, Optional +from urllib.parse import urlparse + +from Engines.modules.logs import log +from Engines.modules.tide import HelperTide + + +class TLPLevel(IntEnum): + """Traffic Light Protocol levels as an ordered enum for comparison. + + The TLP hierarchy is ordered from least restrictive to most restrictive: + CLEAR (0) < GREEN (1) < AMBER (2) < AMBER_STRICT (3) < RED (4) + + This allows direct comparison using standard operators: + TLPLevel.GREEN < TLPLevel.AMBER # True + TLPLevel.CLEAR <= TLPLevel.GREEN # True + """ + CLEAR = 0 # aliases: "white", "clear" + GREEN = 1 + AMBER = 2 + AMBER_STRICT = 3 # "amber+strict" + RED = 4 + + @classmethod + def from_string(cls, value: str) -> "TLPLevel": + """Parse a TLP string (case-insensitive, white=clear) into a TLPLevel. + + Args: + value: A string representing a TLP level. Accepted values are: + - "clear" or "white" → CLEAR + - "green" → GREEN + - "amber" → AMBER + - "amber+strict" → AMBER_STRICT + - "red" → RED + + Returns: + The corresponding TLPLevel enum member. + + Raises: + ValueError: If the string does not match any valid TLP level. + + Examples: + >>> TLPLevel.from_string("green") + + >>> TLPLevel.from_string("WHITE") + + >>> TLPLevel.from_string("AMBER+STRICT") + + """ + normalized = value.strip().lower() + + mapping = { + "clear": cls.CLEAR, + "white": cls.CLEAR, # white is an alias for clear + "green": cls.GREEN, + "amber": cls.AMBER, + "amber+strict": cls.AMBER_STRICT, + "red": cls.RED, + } + + if normalized not in mapping: + valid_values = ["clear", "white", "green", "amber", "amber+strict", "red"] + raise ValueError( + f"Invalid TLP level: '{value}'. " + f"Valid values are: {', '.join(valid_values)}" + ) + + return mapping[normalized] + + def to_misp_tag(self) -> str: + """Return the MISP taxonomy tag string for this TLP level. + + Returns: + A string in the format 'tlp:' suitable for use as a MISP tag. + + Examples: + >>> TLPLevel.GREEN.to_misp_tag() + 'tlp:green' + >>> TLPLevel.AMBER_STRICT.to_misp_tag() + 'tlp:amber+strict' + >>> TLPLevel.CLEAR.to_misp_tag() + 'tlp:clear' + """ + tag_mapping = { + TLPLevel.CLEAR: "tlp:clear", + TLPLevel.GREEN: "tlp:green", + TLPLevel.AMBER: "tlp:amber", + TLPLevel.AMBER_STRICT: "tlp:amber+strict", + TLPLevel.RED: "tlp:red", + } + return tag_mapping[self] + + +@dataclass +class MISPInstanceConfig: + """Configuration for a single MISP instance connection. + + Represents the configuration for connecting to and sharing objects with + a specific MISP server instance. + + Attributes: + name: Human-readable name for this instance (1-128 characters). + url: Base URL of the MISP instance (HTTP/HTTPS). + token: API token for authentication. If prefixed with '$', the value + is resolved from the corresponding environment variable. + org_uuid: UUID of the organisation under which events are created. + max_allowed_tlp: Maximum TLP level allowed for sharing to this instance. + Objects with higher TLP are excluded from sharing. + mode: Operation mode - 'send' (push only), 'fetch' (pull only), + or 'sync' (bidirectional). + proxy: Whether to route connections through the configured proxy. + publish_on_change: Whether to auto-publish events after creation/update. + verify_ssl: Whether to validate TLS certificates for this instance. + """ + name: str # 1-128 chars + url: str # valid HTTP/HTTPS URL + token: str # resolved from env var if $-prefixed + org_uuid: str # UUIDv4 + max_allowed_tlp: TLPLevel + mode: Literal["send", "fetch", "sync"] + proxy: bool + publish_on_change: bool + verify_ssl: bool + + +@dataclass +class OrganisationConfig: + """Configuration for the sharing organisation identity. + + Represents the organisation that owns and shares OpenTIDE objects + to configured MISP instances. + + Attributes: + enabled: Whether sharing is enabled for this organisation. + If False, all sharing operations are skipped. + name: Human-readable name of the organisation (1-256 characters). + uuid: UUID of the organisation in UUIDv4 format. + """ + enabled: bool + name: str # 1-256 chars + uuid: str # UUIDv4 + + +@dataclass +class SharingConfig: + """Top-level sharing configuration container. + + Aggregates the organisation configuration and list of MISP instance + configurations for the sharing pipeline. + + Attributes: + organisation: Configuration for the sharing organisation identity. + instances: List of MISP instance configurations to share objects with. + """ + organisation: OrganisationConfig + instances: List[MISPInstanceConfig] = field(default_factory=list) + + +# Fixed namespace UUID for deterministic event UUID derivation +# This is the opentide MISP object template UUID +OPENTIDE_NAMESPACE_UUID = uuid.UUID("892fd46a-f69e-455c-8c4f-843a4b8f4295") + + +def derive_event_uuid(opentide_uuid: str) -> str: + """Deterministically derive a MISP event UUID from an OpenTIDE object UUID. + + Uses UUID5 with a fixed namespace (the opentide MISP object template UUID) + to ensure consistent event identity across all MISP instances. This means + the same OpenTIDE object will always map to the same MISP event UUID, + regardless of which instance it's pushed to or how many times the + derivation is performed. + + Args: + opentide_uuid: The UUID of the OpenTIDE object (metadata.uuid). + + Returns: + A string representation of the derived UUID5. + + Examples: + >>> derive_event_uuid("550e8400-e29b-41d4-a716-446655440000") + '...' # Always returns the same value for the same input + >>> derive_event_uuid("550e8400-e29b-41d4-a716-446655440000") == \\ + ... derive_event_uuid("550e8400-e29b-41d4-a716-446655440000") + True + """ + return str(uuid.uuid5(OPENTIDE_NAMESPACE_UUID, opentide_uuid)) + + +# UUID validation regex pattern +_UUID_V4_PATTERN = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + re.IGNORECASE +) + +# Valid modes for MISP instance configuration +_VALID_MODES = {"send", "fetch", "sync"} + + +def _is_valid_uuid_v4(value: str) -> bool: + """Check if a string is a valid UUIDv4 format. + + Args: + value: String to validate. + + Returns: + True if the string matches UUIDv4 format, False otherwise. + """ + return bool(_UUID_V4_PATTERN.match(value)) + + +def _is_valid_url(value: str) -> bool: + """Check if a string is a valid HTTP or HTTPS URL. + + Args: + value: String to validate. + + Returns: + True if the string is a valid HTTP/HTTPS URL, False otherwise. + """ + try: + result = urlparse(value) + return result.scheme in ('http', 'https') and bool(result.netloc) + except Exception: + return False + + +def _resolve_env_token(value: str, field_name: str, context: str) -> str: + """Resolve a $-prefixed environment variable token. + + If the value starts with '$', it will be resolved via HelperTide.fetch_config_envvar(). + Otherwise, the value is returned unchanged. + + Args: + value: The value to potentially resolve. + field_name: Name of the field being resolved (for error reporting). + context: Context description for error messages (e.g., "MISP instance 'Internal'"). + + Returns: + The resolved value. + + Raises: + ValueError: If the environment variable is not found. + """ + if not isinstance(value, str) or not value.startswith('$'): + return value + + # Use HelperTide.fetch_config_envvar which handles the env var resolution + # Create a temporary dict to leverage the existing method + temp_config = {field_name: value} + resolved = HelperTide.fetch_config_envvar(temp_config) + + # If the value wasn't resolved (still starts with $), it means env var was not found + if resolved[field_name].startswith('$'): + env_var_name = value[1:] # Remove the $ prefix + log("FATAL", f"Missing environment variable '{env_var_name}' for {field_name} in {context}") + raise ValueError(f"Missing environment variable '{env_var_name}' for {field_name} in {context}") + + return resolved[field_name] + + +def load_sharing_config(sharing_toml: dict, deployment_toml: dict) -> SharingConfig: + """Parse and validate sharing.toml content alongside deployment.toml proxy settings. + + Parses the sharing configuration from TOML dictionaries and validates all fields + against their type constraints. Environment variable tokens (prefixed with '$') + are resolved via HelperTide.fetch_config_envvar(). + + Args: + sharing_toml: Dictionary containing the parsed sharing.toml content. + Expected structure: + - [organisation] section with enabled, name, uuid + - [[misp]] array with instance configurations + deployment_toml: Dictionary containing the parsed deployment.toml content. + Used to validate proxy consistency ([debug].proxy_enabled). + + Returns: + A validated SharingConfig dataclass containing the organisation config + and list of MISP instance configurations. + + Raises: + ValueError: If any field fails validation (invalid type, length, format) + or if required environment variables are missing. + + Requirements: + - 1.1: Parse [organisation] section with enabled, name, uuid fields + - 1.2: Parse [[misp]] array entries with all required fields + - 1.3: Resolve $-prefixed tokens via HelperTide.fetch_config_envvar() + - 1.4: Log FATAL and raise on missing environment variables + - 1.6: Validate proxy consistency + - 1.7: Validate mode values (send, fetch, sync) + - 1.11: Validate field types and constraints + - 1.12: Validate proxy=true requires proxy_enabled=true + """ + errors: List[str] = [] + + # Validate organisation section exists + org_section = sharing_toml.get("organisation") + if not org_section: + log("FATAL", "Missing [organisation] section in sharing.toml") + raise ValueError("Missing [organisation] section in sharing.toml") + + # Parse and validate organisation fields + org_enabled = org_section.get("enabled") + org_name = org_section.get("name", "") + org_uuid = org_section.get("uuid", "") + + # Validate enabled is boolean + if not isinstance(org_enabled, bool): + errors.append(f"organisation.enabled must be a boolean, got {type(org_enabled).__name__}") + + # Validate name is string with 1-256 chars + if not isinstance(org_name, str): + errors.append(f"organisation.name must be a string, got {type(org_name).__name__}") + elif org_enabled and (len(org_name) < 1 or len(org_name) > 256): + errors.append(f"organisation.name must be 1-256 characters, got {len(org_name)}") + + # Validate uuid is valid UUIDv4 format (only if enabled) + if not isinstance(org_uuid, str): + errors.append(f"organisation.uuid must be a string, got {type(org_uuid).__name__}") + elif org_enabled and org_uuid and not _is_valid_uuid_v4(org_uuid): + errors.append(f"organisation.uuid must be a valid UUIDv4 format, got '{org_uuid}'") + + # Check for fatal organisation errors before proceeding + if errors: + for error in errors: + log("FATAL", error) + raise ValueError(f"Configuration validation failed: {'; '.join(errors)}") + + # Create OrganisationConfig + organisation = OrganisationConfig( + enabled=org_enabled, + name=org_name, + uuid=org_uuid + ) + + # Get proxy_enabled from deployment.toml [debug] section + debug_section = deployment_toml.get("debug", {}) + proxy_enabled_globally = debug_section.get("proxy_enabled", False) + + # Parse MISP instances + misp_instances: List[MISPInstanceConfig] = [] + misp_entries = sharing_toml.get("misp", []) + + # Handle the case where misp is not a list (single entry without array syntax) + if isinstance(misp_entries, dict): + misp_entries = [misp_entries] + + for idx, misp_entry in enumerate(misp_entries): + instance_errors: List[str] = [] + instance_name = misp_entry.get("name", f"instance_{idx}") + context = f"MISP instance '{instance_name}' (index {idx})" + + # Validate name (1-128 chars) + name = misp_entry.get("name") + if not isinstance(name, str): + instance_errors.append(f"name must be a string, got {type(name).__name__}") + elif len(name) < 1 or len(name) > 128: + instance_errors.append(f"name must be 1-128 characters, got {len(name)}") + + # Validate url (valid HTTP/HTTPS URL) + url = misp_entry.get("url") + if not isinstance(url, str): + instance_errors.append(f"url must be a string, got {type(url).__name__}") + elif not _is_valid_url(url): + instance_errors.append(f"url must be a valid HTTP/HTTPS URL, got '{url}'") + + # Validate and resolve token + token = misp_entry.get("token") + if not isinstance(token, str): + instance_errors.append(f"token must be a string, got {type(token).__name__}") + else: + try: + token = _resolve_env_token(token, "token", context) + except ValueError as e: + instance_errors.append(str(e)) + + # Validate org_uuid (UUIDv4) + inst_org_uuid = misp_entry.get("org_uuid") + if not isinstance(inst_org_uuid, str): + instance_errors.append(f"org_uuid must be a string, got {type(inst_org_uuid).__name__}") + elif not _is_valid_uuid_v4(inst_org_uuid): + instance_errors.append(f"org_uuid must be a valid UUIDv4 format, got '{inst_org_uuid}'") + + # Validate max_allowed_tlp + max_tlp_str = misp_entry.get("max_allowed_tlp") + max_allowed_tlp = None + if not isinstance(max_tlp_str, str): + instance_errors.append(f"max_allowed_tlp must be a string, got {type(max_tlp_str).__name__}") + else: + try: + max_allowed_tlp = TLPLevel.from_string(max_tlp_str) + except ValueError as e: + instance_errors.append(f"max_allowed_tlp: {e}") + + # Validate mode (send, fetch, sync) + mode = misp_entry.get("mode") + if not isinstance(mode, str): + instance_errors.append(f"mode must be a string, got {type(mode).__name__}") + elif mode not in _VALID_MODES: + instance_errors.append(f"mode must be one of {_VALID_MODES}, got '{mode}'") + + # Validate proxy (boolean) + proxy = misp_entry.get("proxy") + if not isinstance(proxy, bool): + instance_errors.append(f"proxy must be a boolean, got {type(proxy).__name__}") + + # Validate proxy consistency: proxy=true requires proxy_enabled=true in deployment.toml + if isinstance(proxy, bool) and proxy and not proxy_enabled_globally: + instance_errors.append( + f"proxy is set to true but proxy_enabled is false in deployment.toml [debug] section. " + "Enable proxy globally in deployment.toml or set proxy to false for this instance." + ) + + # Validate publish_on_change (boolean) + publish_on_change = misp_entry.get("publish_on_change") + if not isinstance(publish_on_change, bool): + instance_errors.append(f"publish_on_change must be a boolean, got {type(publish_on_change).__name__}") + + # Validate verify_ssl (boolean) + verify_ssl = misp_entry.get("verify_ssl") + if not isinstance(verify_ssl, bool): + instance_errors.append(f"verify_ssl must be a boolean, got {type(verify_ssl).__name__}") + + # If there are errors for this instance, log and collect them + if instance_errors: + for error in instance_errors: + log("FATAL", f"{context}: {error}") + errors.extend([f"{context}: {e}" for e in instance_errors]) + continue + + # Create MISPInstanceConfig if all validations pass + misp_instances.append(MISPInstanceConfig( + name=name, + url=url, + token=token, + org_uuid=inst_org_uuid, + max_allowed_tlp=max_allowed_tlp, # type: ignore + mode=mode, # type: ignore + proxy=proxy, + publish_on_change=publish_on_change, + verify_ssl=verify_ssl + )) + + # If there were any errors during instance parsing, raise + if errors: + raise ValueError(f"Configuration validation failed: {'; '.join(errors)}") + + return SharingConfig( + organisation=organisation, + instances=misp_instances + ) diff --git a/Engines/requirements.txt b/Engines/requirements.txt index d79942fe..e4ecb9ce 100644 --- a/Engines/requirements.txt +++ b/Engines/requirements.txt @@ -11,4 +11,6 @@ carbon-black-cloud-sdk toml azure-mgmt-securityinsight==2.0.0b2 azure-identity -azure-monitor-query \ No newline at end of file +azure-monitor-query +pymisp +hypothesis \ No newline at end of file diff --git a/Engines/sharing/__init__.py b/Engines/sharing/__init__.py new file mode 100644 index 00000000..ca8bf41e --- /dev/null +++ b/Engines/sharing/__init__.py @@ -0,0 +1,7 @@ +"""Engines/sharing/ — MISP Sharing Pipeline engine. + +This engine publishes OpenTIDE objects (TVM, DOM, MDR) as MISP events +to configured MISP instances. It follows the CoreTIDE engine pattern +with modules for connection, scope filtering, event management, tagging, +and relation resolution. +""" diff --git a/Engines/sharing/connector.py b/Engines/sharing/connector.py new file mode 100644 index 00000000..0bce25ef --- /dev/null +++ b/Engines/sharing/connector.py @@ -0,0 +1,223 @@ +"""Engines/sharing/connector.py — MISP connection factory. + +This module provides the PyMISP client factory for connecting to MISP instances +with configurable SSL verification, proxy settings, and error handling. +""" + +import socket +import ssl +from typing import Optional + +from pymisp import PyMISP + +from Engines.modules.sharing import MISPInstanceConfig +from Engines.modules.logs import log + + +# Connection timeout in seconds for MISP API operations +CONNECTION_TIMEOUT = 30 + + +def create_misp_client( + instance_config: MISPInstanceConfig, + proxy_config: Optional[dict] = None +) -> Optional[PyMISP]: + """Create a PyMISP client for the given instance configuration. + + Creates and configures a PyMISP instance for connecting to a MISP server. + Handles SSL verification settings, proxy configuration with optional + authentication, and connection failure scenarios. + + Args: + instance_config: Validated MISP instance configuration containing: + - url: Base URL of the MISP instance + - token: API authentication token + - verify_ssl: Whether to validate TLS certificates + - proxy: Whether to route through configured proxy + proxy_config: Optional proxy settings from deployment.toml [proxy] section. + Expected keys when provided: + - proxy_host: Proxy server hostname + - proxy_port: Proxy server port + - proxy_user: Optional proxy username for authentication + - proxy_password: Optional proxy password for authentication + + Returns: + A configured PyMISP instance ready for API operations, or None if + connection fails. When None is returned, a FATAL log has been emitted + and the caller should proceed to the next instance. + + Behavior: + - Sets ssl=instance_config.verify_ssl on PyMISP initialization + - If instance_config.proxy is True and proxy_config is provided, + configures HTTP/HTTPS proxy from proxy_config + - If proxy auth credentials (proxy_user and proxy_password) are + non-empty, includes them in the proxy URL + - Logs FATAL on connection failure (timeout 30s, DNS failure, SSL error) + - Returns None on failure so caller can proceed to next instance + + Requirements: + - 1.5: Direct connection when proxy is False + - 1.6: Proxy routing when proxy is True and proxy_enabled is True + - 1.13: Skip TLS verification when verify_ssl is False + - 9.1: Validate TLS certificate when verify_ssl is True + - 9.2: Skip TLS validation when verify_ssl is False + - 9.3: Configure proxy from deployment.toml [proxy] section + - 9.4: Include proxy auth credentials when non-empty + - 9.5: Log FATAL on TLS certificate validation failure + - 9.6: Log FATAL on proxy connection failure + """ + try: + # Build proxy URL if proxy is enabled + proxies = None + if instance_config.proxy and proxy_config: + proxies = _build_proxy_dict(proxy_config, instance_config.name) + if proxies is None: + # Proxy configuration failed, FATAL already logged + return None + + # Create PyMISP client with configured settings + # PyMISP accepts ssl parameter for certificate verification + # and proxies dict for HTTP/HTTPS proxy configuration + client = PyMISP( + url=instance_config.url, + key=instance_config.token, + ssl=instance_config.verify_ssl, + timeout=CONNECTION_TIMEOUT, + proxies=proxies + ) + + # Verify connectivity by fetching MISP version + # This validates the connection, authentication, and SSL settings + version_response = client.misp_instance_version + + if not version_response or isinstance(version_response, dict) and 'errors' in version_response: + error_msg = version_response.get('errors', 'Unknown error') if isinstance(version_response, dict) else 'No response' + log( + "FATAL", + f"Failed to connect to MISP instance '{instance_config.name}'", + f"URL: {instance_config.url}", + f"Error: {error_msg}" + ) + return None + + log( + "SUCCESS", + f"Connected to MISP instance '{instance_config.name}'", + f"MISP version: {version_response.get('version', 'unknown') if isinstance(version_response, dict) else 'unknown'}" + ) + + return client + + except ssl.SSLError as e: + log( + "FATAL", + f"SSL certificate validation failed for MISP instance '{instance_config.name}'", + f"URL: {instance_config.url}", + f"SSL Error: {str(e)}. If the server uses a self-signed certificate, set verify_ssl=false in sharing.toml" + ) + return None + + except socket.timeout: + log( + "FATAL", + f"Connection timeout for MISP instance '{instance_config.name}'", + f"URL: {instance_config.url}", + f"The connection timed out after {CONNECTION_TIMEOUT} seconds. Check network connectivity and firewall rules." + ) + return None + + except socket.gaierror as e: + log( + "FATAL", + f"DNS resolution failed for MISP instance '{instance_config.name}'", + f"URL: {instance_config.url}", + f"Could not resolve hostname: {str(e)}" + ) + return None + + except ConnectionRefusedError: + log( + "FATAL", + f"Connection refused by MISP instance '{instance_config.name}'", + f"URL: {instance_config.url}", + "The server actively refused the connection. Verify the URL and port are correct." + ) + return None + + except ConnectionError as e: + log( + "FATAL", + f"Connection error for MISP instance '{instance_config.name}'", + f"URL: {instance_config.url}", + f"Connection error: {str(e)}" + ) + return None + + except Exception as e: + log( + "FATAL", + f"Unexpected error connecting to MISP instance '{instance_config.name}'", + f"URL: {instance_config.url}", + f"Error: {type(e).__name__}: {str(e)}" + ) + return None + + +def _build_proxy_dict( + proxy_config: dict, + instance_name: str +) -> Optional[dict]: + """Build a proxy dictionary for PyMISP from deployment.toml proxy config. + + Constructs the proxy URLs for HTTP and HTTPS connections based on the + provided proxy configuration. Supports both authenticated and + unauthenticated proxy connections. + + Args: + proxy_config: Dictionary containing proxy configuration with keys: + - proxy_host: Proxy server hostname (required) + - proxy_port: Proxy server port (required) + - proxy_user: Optional username for proxy authentication + - proxy_password: Optional password for proxy authentication + instance_name: Name of the MISP instance for logging purposes. + + Returns: + A dictionary with 'http' and 'https' keys mapping to proxy URLs, + or None if required proxy configuration is missing. + """ + proxy_host = proxy_config.get("proxy_host") + proxy_port = proxy_config.get("proxy_port") + + if not proxy_host or not proxy_port: + log( + "FATAL", + f"Proxy enabled for MISP instance '{instance_name}' but proxy configuration is incomplete", + f"Missing proxy_host or proxy_port in deployment.toml [proxy] section", + "Ensure both proxy_host and proxy_port are configured in deployment.toml" + ) + return None + + # Build proxy URL with optional authentication + proxy_user = proxy_config.get("proxy_user") + proxy_password = proxy_config.get("proxy_password") + + # Include credentials only when both user and password are non-empty + if proxy_user and proxy_password: + proxy_url = f"http://{proxy_user}:{proxy_password}@{proxy_host}:{proxy_port}" + log( + "INFO", + f"Using authenticated proxy for MISP instance '{instance_name}'", + f"Proxy: {proxy_host}:{proxy_port} (with authentication)" + ) + else: + proxy_url = f"http://{proxy_host}:{proxy_port}" + log( + "INFO", + f"Using unauthenticated proxy for MISP instance '{instance_name}'", + f"Proxy: {proxy_host}:{proxy_port}" + ) + + return { + "http": proxy_url, + "https": proxy_url + } diff --git a/Engines/sharing/events.py b/Engines/sharing/events.py new file mode 100644 index 00000000..c414dc66 --- /dev/null +++ b/Engines/sharing/events.py @@ -0,0 +1,688 @@ +"""Engines/sharing/events.py — MISP event existence check, creation, and update. + +This module handles the MISP event lifecycle operations: +- Checking for existing events matching an OpenTIDE object +- Creating new MISP events for OpenTIDE objects +- Updating existing MISP events when object versions change + +Each OpenTIDE object maps to exactly one MISP event containing a single +opentide MISP object. Event UUIDs are derived deterministically from +the OpenTIDE object UUID for consistent identity across instances. +""" + +from dataclasses import dataclass +from typing import List, Optional, Literal, Tuple + +import yaml +from pymisp import PyMISP, MISPEvent, MISPObject, MISPTag + +from Engines.modules.logs import log +from Engines.modules.sharing import MISPInstanceConfig, TLPLevel, derive_event_uuid +from Engines.sharing.relations import resolve_relations + +# Import tagging functions if available (Task 7.1) +# These will be replaced with actual imports once tagging.py is created +try: + from Engines.sharing.tagging import build_tlp_tag, build_attack_tags, build_actor_galaxies + _TAGGING_AVAILABLE = True +except ImportError: + _TAGGING_AVAILABLE = False + + def build_tlp_tag(tlp: TLPLevel) -> MISPTag: + """Placeholder for TLP tag builder. Creates a basic MISPTag.""" + tag = MISPTag() + tag.name = tlp.to_misp_tag() + return tag + + def build_attack_tags(object_uuid: str) -> List[MISPTag]: + """Placeholder for ATT&CK tag builder. Returns empty list.""" + return [] + + def build_actor_galaxies( + object_type: str, + object_data: dict, + misp_client: PyMISP + ) -> List[dict]: + """Placeholder for actor galaxy builder. Returns empty list.""" + return [] + + +# API timeout in seconds for MISP operations +API_TIMEOUT = 30 + +ObjectType = Literal["tvm", "dom", "mdr"] + + +@dataclass +class ExistenceResult: + """Result of checking for an existing MISP event. + + Encapsulates the result of querying MISP for an existing event that + matches an OpenTIDE object by org_uuid and opentide uuid attribute. + + Attributes: + found: Whether a matching event was found. + event: The matching MISPEvent if found, None otherwise. + remote_version: The version attribute from the opentide object + in the existing event. Defaults to 0 if missing/unparseable. + """ + found: bool + event: Optional[MISPEvent] = None + remote_version: int = 0 + + +def check_existence( + client: PyMISP, + org_uuid: str, + opentide_uuid: str +) -> ExistenceResult: + """Query MISP for existing events matching the OpenTIDE object. + + Searches the target MISP instance for events that: + - Are owned by the specified organisation (org_uuid) + - Contain an opentide object with a uuid attribute matching opentide_uuid + + Args: + client: Configured PyMISP client for the target instance. + org_uuid: UUID of the organisation under which events are created. + opentide_uuid: UUID of the OpenTIDE object to search for. + + Returns: + ExistenceResult with: + - found=True and event populated if a matching event exists + - found=False if no match or on API error + - remote_version extracted from the opentide object's version attribute + (defaults to 0 if missing or unparseable) + + Behavior: + - If multiple matches: uses most recently modified, logs FAILURE about duplicates + - If API error: logs FAILURE, returns ExistenceResult(found=False) + - If version attribute missing/unparseable: treats remote_version as 0 + - Timeout: 30 seconds per API call + + Requirements: + - 3.1: Query by org_uuid and opentide uuid attribute + - 3.2: Filter to events owned by org_uuid + - 3.3: Handle multiple matches - use most recently modified + - 3.4: Handle API errors gracefully + - 4.5: Handle missing/unparseable version as 0 + """ + try: + # Search for events containing an opentide object with matching uuid + # We search by: + # 1. Organisation UUID (events created by our org) + # 2. Object type "opentide" + # 3. Attribute value matching the OpenTIDE UUID + # + # PyMISP search_index allows us to search for events by various criteria. + # We use returnFormat='json' to get full event details. + search_result = client.search( + controller='events', + org=org_uuid, + object_name='opentide', + value=opentide_uuid, + pythonify=True, + timeout=API_TIMEOUT + ) + + # Handle empty or error responses + if not search_result: + return ExistenceResult(found=False) + + # Filter to events that actually contain an opentide object with matching uuid + matching_events = [] + for event in search_result: + if isinstance(event, MISPEvent): + # Verify the event contains an opentide object with the right uuid + if _event_has_matching_opentide(event, opentide_uuid): + matching_events.append(event) + + if not matching_events: + return ExistenceResult(found=False) + + # Handle multiple matches: use most recently modified, log FAILURE about duplicates + if len(matching_events) > 1: + log( + "FAILURE", + f"Duplicate MISP events found for OpenTIDE object", + f"UUID: {opentide_uuid}, Found {len(matching_events)} events", + "Using the most recently modified event. Consider cleaning up duplicates." + ) + # Sort by timestamp descending (most recent first) + # MISPEvent.timestamp is the modification timestamp + matching_events.sort( + key=lambda e: int(getattr(e, 'timestamp', 0) or 0), + reverse=True + ) + + # Use the most recently modified (or only) event + selected_event = matching_events[0] + + # Extract the version from the opentide object + remote_version = _extract_opentide_version(selected_event, opentide_uuid) + + return ExistenceResult( + found=True, + event=selected_event, + remote_version=remote_version + ) + + except Exception as e: + log( + "FAILURE", + f"API error during existence check for OpenTIDE object", + f"UUID: {opentide_uuid}, Error: {type(e).__name__}: {str(e)}", + "Skipping this object for the current MISP instance." + ) + return ExistenceResult(found=False) + + +def _event_has_matching_opentide(event: MISPEvent, opentide_uuid: str) -> bool: + """Check if a MISP event contains an opentide object with the given UUID. + + Iterates through all objects in the event looking for an opentide object + that has a uuid attribute matching the provided opentide_uuid. + + Args: + event: The MISPEvent to check. + opentide_uuid: The OpenTIDE UUID to search for. + + Returns: + True if a matching opentide object is found, False otherwise. + """ + # Get objects from the event + objects = getattr(event, 'Object', []) or [] + + for obj in objects: + # Check if this is an opentide object + obj_name = getattr(obj, 'name', None) + if obj_name != 'opentide': + continue + + # Check if it has a uuid attribute matching our target + attributes = getattr(obj, 'Attribute', []) or [] + for attr in attributes: + attr_type = getattr(attr, 'object_relation', None) + attr_value = getattr(attr, 'value', None) + + # The uuid attribute in opentide object stores the OpenTIDE object UUID + if attr_type == 'uuid' and attr_value == opentide_uuid: + return True + + return False + + +def _extract_opentide_version(event: MISPEvent, opentide_uuid: str) -> int: + """Extract the version attribute from the matching opentide object. + + Searches for the opentide object with the given UUID and extracts + its version attribute value. + + Args: + event: The MISPEvent containing the opentide object. + opentide_uuid: The OpenTIDE UUID to identify the correct object. + + Returns: + The version as an integer, or 0 if missing/unparseable. + + Requirements: + - 4.5: Treat missing/unparseable version as 0 + """ + objects = getattr(event, 'Object', []) or [] + + for obj in objects: + # Check if this is an opentide object + obj_name = getattr(obj, 'name', None) + if obj_name != 'opentide': + continue + + attributes = getattr(obj, 'Attribute', []) or [] + + # First verify this is the right opentide object + is_matching_object = False + version_value = None + + for attr in attributes: + attr_type = getattr(attr, 'object_relation', None) + attr_value = getattr(attr, 'value', None) + + if attr_type == 'uuid' and attr_value == opentide_uuid: + is_matching_object = True + elif attr_type == 'version': + version_value = attr_value + + # If this is our object, return its version + if is_matching_object: + if version_value is None: + return 0 + + # Try to parse as integer + try: + return int(version_value) + except (ValueError, TypeError): + # Version attribute present but not parseable as integer + return 0 + + # No matching opentide object found (shouldn't happen if event passed filtering) + return 0 + + +# Template UUID for the opentide MISP object +OPENTIDE_TEMPLATE_UUID = "892fd46a-f69e-455c-8c4f-843a4b8f4295" + + +def build_opentide_misp_object( + object_uuid: str, + object_type: ObjectType, + object_data: dict, + object_name: str +) -> MISPObject: + """Build the opentide MISP object with required attributes. + + Creates a MISPObject using the opentide template and populates it with + the required attributes from the OpenTIDE object data. + + Args: + object_uuid: The UUID of the OpenTIDE object (metadata.uuid). + object_type: The type of object ("tvm", "dom", or "mdr"). + object_data: The full object dictionary from DataTide. + object_name: The display name/title of the object. + + Returns: + A MISPObject with the following attributes: + - name: Object title (object_name) + - opentide-object: Full object content serialized as YAML + - opentide-type: "tvm", "dom", or "mdr" + - uuid: The OpenTIDE object UUID + - version: The object version as a string + - opentide-relation: UUIDs of related objects (multi-value, omitted if empty) + + Requirements: + - 5.1: One MISP event per OpenTIDE object (1:1 mapping) + - 5.3: Required attributes: name, opentide-object, opentide-type, uuid, version + - 5.4: opentide-relation with UUIDs according to object type rules + - 5.5, 5.6, 5.7: Type-specific relation resolution + - 5.8: Omit opentide-relation if no resolvable relations + """ + # Create the MISP object with the opentide template UUID + misp_object = MISPObject( + name="opentide", + template_uuid=OPENTIDE_TEMPLATE_UUID, + standalone=False + ) + + # Extract metadata for version + metadata = object_data.get("metadata", {}) + version = metadata.get("version", 0) + + # Add required attributes + # name: Object title + misp_object.add_attribute( + object_relation="name", + value=object_name + ) + + # opentide-object: Full object content serialized as YAML + yaml_content = yaml.dump( + object_data, + default_flow_style=False, + allow_unicode=True, + sort_keys=False + ) + misp_object.add_attribute( + object_relation="opentide-object", + value=yaml_content + ) + + # opentide-type: "tvm", "dom", or "mdr" + misp_object.add_attribute( + object_relation="opentide-type", + value=object_type + ) + + # uuid: The OpenTIDE object UUID + misp_object.add_attribute( + object_relation="uuid", + value=object_uuid + ) + + # version: The object version as a string + misp_object.add_attribute( + object_relation="version", + value=str(version) + ) + + # Resolve relations and add opentide-relation if non-empty + relations = resolve_relations( + object_uuid=object_uuid, + object_type=object_type, + object_data=object_data + ) + + # Only add opentide-relation if there are relations to add + # Each UUID is a separate value of the multi-value attribute + if relations: + for relation_uuid in relations: + misp_object.add_attribute( + object_relation="opentide-relation", + value=relation_uuid + ) + + return misp_object + + + +def create_event( + client: PyMISP, + instance_config: MISPInstanceConfig, + object_uuid: str, + object_type: ObjectType, + object_data: dict, + object_name: str, + tlp: TLPLevel +) -> bool: + """Create a new MISP event for an OpenTIDE object. + + Creates a MISP event with a deterministically-derived UUID, attaches the + opentide MISP object containing the full object data, and applies + appropriate tags and galaxy clusters. + + Args: + client: Configured PyMISP client for the target instance. + instance_config: Configuration for the target MISP instance. + object_uuid: The UUID of the OpenTIDE object (metadata.uuid). + object_type: The type of object ("tvm", "dom", or "mdr"). + object_data: The full object dictionary from DataTide. + object_name: The display name/title of the object. + tlp: The TLP level of the object for tagging. + + Returns: + True on successful creation, False on failure. + + Behavior: + - Derives event UUID deterministically from object UUID + - Sets event org to instance_config.org_uuid + - Attaches opentide MISP object with all required attributes + - Applies TLP tag, ATT&CK tags, and actor galaxy clusters + - If publish_on_change: calls client.publish(event, alert=False) + - Logs SUCCESS on successful creation + - Logs FAILURE and returns False on any error + + Requirements: + - 4.4: Create new event when no matching event found + - 5.1: One MISP event per OpenTIDE object (1:1 mapping) + - 5.9: Derive event UUID deterministically + - 1.8: Publish without email when publish_on_change is True + - 6.1, 6.2, 6.3, 6.4: Apply appropriate tags and galaxies + """ + try: + # Create a new MISP event + event = MISPEvent() + + # Derive event UUID deterministically from OpenTIDE object UUID + event.uuid = derive_event_uuid(object_uuid) + + # Set event info with object name + event.info = f"OpenTIDE {object_type.upper()}: {object_name}" + + # Set the organisation UUID for this event + event.orgc_uuid = instance_config.org_uuid + + # Build and attach the opentide MISP object + opentide_object = build_opentide_misp_object( + object_uuid=object_uuid, + object_type=object_type, + object_data=object_data, + object_name=object_name + ) + event.add_object(opentide_object) + + # Apply TLP tag + tlp_tag = build_tlp_tag(tlp) + event.add_tag(tlp_tag) + + # Apply ATT&CK technique tags + attack_tags = build_attack_tags(object_uuid) + for tag in attack_tags: + event.add_tag(tag) + + # Apply threat actor galaxy clusters (TVM only) + actor_galaxies = build_actor_galaxies(object_type, object_data, client) + for galaxy in actor_galaxies: + # Galaxy clusters are added via the Galaxy attribute + if hasattr(event, 'Galaxy') and event.Galaxy is not None: + event.Galaxy.append(galaxy) + else: + event.Galaxy = [galaxy] + + # Add the event to MISP + result = client.add_event(event, pythonify=True, timeout=API_TIMEOUT) + + # Check if the event was created successfully + if isinstance(result, MISPEvent) and result.uuid: + # Publish the event if configured to do so + if instance_config.publish_on_change: + client.publish(result, alert=False) + + log( + "SUCCESS", + f"Created MISP event for OpenTIDE object", + f"Object: {object_name} ({object_uuid})", + f"Instance: {instance_config.name}, Event UUID: {result.uuid}" + ) + return True + else: + # Handle error response from MISP + error_msg = str(result) if result else "Unknown error" + log( + "FAILURE", + f"Failed to create MISP event for OpenTIDE object", + f"Object: {object_name} ({object_uuid})", + f"Instance: {instance_config.name}, Error: {error_msg}" + ) + return False + + except Exception as e: + log( + "FAILURE", + f"Exception creating MISP event for OpenTIDE object", + f"Object: {object_name} ({object_uuid})", + f"Instance: {instance_config.name}, Error: {type(e).__name__}: {str(e)}" + ) + return False + + +def update_event( + client: PyMISP, + instance_config: MISPInstanceConfig, + existing_event: MISPEvent, + object_uuid: str, + object_type: ObjectType, + object_data: dict, + object_name: str, + tlp: TLPLevel +) -> bool: + """Update an existing MISP event with current object state. + + Replaces the opentide MISP object in an existing event with a new one + containing the current object data, and updates all tags and galaxy clusters. + + Args: + client: Configured PyMISP client for the target instance. + instance_config: Configuration for the target MISP instance. + existing_event: The existing MISP event to update. + object_uuid: The UUID of the OpenTIDE object (metadata.uuid). + object_type: The type of object ("tvm", "dom", or "mdr"). + object_data: The full object dictionary from DataTide. + object_name: The display name/title of the object. + tlp: The TLP level of the object for tagging. + + Returns: + True on successful update, False on failure. + + Behavior: + - Replaces the opentide MISP object (removes old, adds new) + - Replaces all tags with current state (TLP, ATT&CK) + - Replaces galaxy clusters with current state + - If publish_on_change: calls client.publish(event, alert=False) + - Logs SUCCESS on successful update + - Logs FAILURE and returns False on any error + + Requirements: + - 4.2: Update existing event when local version > remote version + - 5.3, 5.4: Update opentide object with current attributes + - 1.8: Publish without email when publish_on_change is True + - 6.1, 6.2, 6.3, 6.4: Update tags and galaxies + """ + try: + # Remove existing opentide objects from the event + objects_to_remove = [] + existing_objects = getattr(existing_event, 'Object', []) or [] + for obj in existing_objects: + obj_name = getattr(obj, 'name', None) + if obj_name == 'opentide': + objects_to_remove.append(obj) + + # Delete old opentide objects from MISP + for old_obj in objects_to_remove: + obj_id = getattr(old_obj, 'id', None) or getattr(old_obj, 'uuid', None) + if obj_id: + try: + client.delete_object(obj_id) + except Exception as e: + # Log but continue - we'll add the new object anyway + log( + "FAILURE", + f"Could not delete old opentide object", + f"Object ID: {obj_id}, Error: {str(e)}", + "Continuing with update..." + ) + + # Build new opentide MISP object + new_opentide_object = build_opentide_misp_object( + object_uuid=object_uuid, + object_type=object_type, + object_data=object_data, + object_name=object_name + ) + + # Add the new object to the event + existing_event.add_object(new_opentide_object) + + # Clear existing tags and add new ones + # We need to remove old tags and add current ones + existing_event.Tag = [] + + # Apply TLP tag + tlp_tag = build_tlp_tag(tlp) + existing_event.add_tag(tlp_tag) + + # Apply ATT&CK technique tags + attack_tags = build_attack_tags(object_uuid) + for tag in attack_tags: + existing_event.add_tag(tag) + + # Clear existing galaxies and add new ones + existing_event.Galaxy = [] + + # Apply threat actor galaxy clusters (TVM only) + actor_galaxies = build_actor_galaxies(object_type, object_data, client) + for galaxy in actor_galaxies: + existing_event.Galaxy.append(galaxy) + + # Update event info + existing_event.info = f"OpenTIDE {object_type.upper()}: {object_name}" + + # Update the event in MISP + result = client.update_event(existing_event, pythonify=True, timeout=API_TIMEOUT) + + # Check if the event was updated successfully + if isinstance(result, MISPEvent) and result.uuid: + # Publish the event if configured to do so + if instance_config.publish_on_change: + client.publish(result, alert=False) + + log( + "SUCCESS", + f"Updated MISP event for OpenTIDE object", + f"Object: {object_name} ({object_uuid})", + f"Instance: {instance_config.name}, Event UUID: {result.uuid}" + ) + return True + else: + # Handle error response from MISP + error_msg = str(result) if result else "Unknown error" + log( + "FAILURE", + f"Failed to update MISP event for OpenTIDE object", + f"Object: {object_name} ({object_uuid})", + f"Instance: {instance_config.name}, Error: {error_msg}" + ) + return False + + except Exception as e: + log( + "FAILURE", + f"Exception updating MISP event for OpenTIDE object", + f"Object: {object_name} ({object_uuid})", + f"Instance: {instance_config.name}, Error: {type(e).__name__}: {str(e)}" + ) + return False + + +def should_update_event(local_version: int, remote_version: int) -> Tuple[bool, str]: + """Determine if an event should be updated based on version comparison. + + Implements the version comparison logic for the sharing pipeline. + + Args: + local_version: The version of the local OpenTIDE object. + remote_version: The version from the existing MISP event's opentide object. + Should be 0 if missing or unparseable. + + Returns: + A tuple of (should_update, reason): + - (True, "update") if local version > remote version + - (False, "skip") if local version <= remote version + + Requirements: + - 4.1: Compare versions using integer numeric comparison + - 4.2: Update when local > remote + - 4.3: Skip when local <= remote + - 4.5: Missing/unparseable remote version treated as 0 + """ + if local_version > remote_version: + return (True, "update") + else: + return (False, "skip") + + +def log_skip_version_current( + object_name: str, + object_uuid: str, + local_version: int, + remote_version: int, + instance_name: str +) -> None: + """Log a SKIP message when an object's version is current. + + Helper function to log when an object is skipped because its local + version is not greater than the remote version. + + Args: + object_name: The display name of the OpenTIDE object. + object_uuid: The UUID of the OpenTIDE object. + local_version: The local object version. + remote_version: The remote event's opentide object version. + instance_name: The name of the MISP instance. + + Requirements: + - 4.3: Log SKIP when local version <= remote version + """ + log( + "SKIP", + f"OpenTIDE object version is current, no update needed", + f"Object: {object_name} ({object_uuid})", + f"Local version: {local_version}, Remote version: {remote_version}", + f"Instance: {instance_name}" + ) diff --git a/Engines/sharing/relations.py b/Engines/sharing/relations.py new file mode 100644 index 00000000..c805ec9c --- /dev/null +++ b/Engines/sharing/relations.py @@ -0,0 +1,192 @@ +"""Engines/sharing/relations.py — Resolve opentide-relation attribute values. + +This module handles the resolution of related object UUIDs for the +opentide-relation MISP object attribute. Each object type (TVM, DOM, MDR) +has specific rules for determining its relations: + +- TVM: Recursively traverse DataTide.Models.chaining to collect all + transitively linked TVM UUIDs (cycle-safe via visited set). +- DOM: Return UUIDs from the objective.threats field. +- MDR: Return the single UUID from the detection_model field. +""" + +from typing import List, Literal, Set, Optional + +ObjectType = Literal["tvm", "dom", "mdr"] + + +def resolve_relations( + object_uuid: str, + object_type: ObjectType, + object_data: dict, + chaining_index: Optional[dict] = None +) -> List[str]: + """Resolve the opentide-relation UUIDs for an OpenTIDE object. + + Dispatches to type-specific resolution logic based on the object_type: + - TVM: Recursively traverse chaining index for linked TVM UUIDs + - DOM: Extract UUIDs from objective.threats field + - MDR: Extract UUID from detection_model field + + Args: + object_uuid: The UUID of the object being processed. + object_type: The type of object ("tvm", "dom", or "mdr"). + object_data: The full object dictionary from DataTide. + chaining_index: Optional chaining index dictionary. If None, + will be loaded from DataTide.Models.chaining. + + Returns: + List of related UUIDs. Empty list if no relations resolvable. + Returns empty list (not None) when relations are absent. + """ + if object_type == "tvm": + return _resolve_tvm_relations(object_uuid, chaining_index) + elif object_type == "dom": + return _resolve_dom_relations(object_data) + elif object_type == "mdr": + return _resolve_mdr_relations(object_data) + else: + # Unknown object type, return empty list + return [] + + +def _resolve_tvm_relations( + tvm_uuid: str, + chaining_index: Optional[dict] = None +) -> List[str]: + """Resolve all TVM chain relations for a given TVM. + + Loads the chaining index from DataTide if not provided and + recursively resolves all transitively linked TVM UUIDs. + + Args: + tvm_uuid: The TVM UUID to resolve chains for. + chaining_index: Optional pre-loaded chaining index. + + Returns: + List of all transitively reachable TVM UUIDs (deduplicated). + """ + if chaining_index is None: + # Lazy import to avoid circular dependencies + from Engines.modules.tide import DataTide + chaining_index = DataTide.Models.chaining + + return _resolve_tvm_chains(tvm_uuid, chaining_index) + + +def _resolve_tvm_chains( + tvm_uuid: str, + chaining_index: dict, + visited: Optional[Set[str]] = None +) -> List[str]: + """Recursively resolve all chained TVM UUIDs from the chaining index. + + Traverses all relations for the given TVM, collecting vector UUIDs. + For each collected UUID that itself has chaining entries, recurse. + Uses a visited set to prevent infinite loops in cyclic chains. + + The chaining_index structure is: + { + tvm_uuid: { + relation_name: [vector_uuid, ...], + ... + }, + ... + } + + Args: + tvm_uuid: The TVM to resolve chains for. + chaining_index: DataTide.Models.chaining dictionary. + visited: Set of already-visited UUIDs (cycle protection). + + Returns: + Flat list of all transitively reachable TVM UUIDs (deduplicated). + """ + if visited is None: + visited = set() + + # Protect against cycles by checking if we've already visited this UUID + if tvm_uuid in visited: + return [] + + # Mark this TVM as visited + visited.add(tvm_uuid) + + # Collect all directly linked UUIDs from this TVM + direct_relations: List[str] = [] + + if tvm_uuid in chaining_index: + tvm_chains = chaining_index[tvm_uuid] + # Iterate over all relation types (e.g., "succeeds", "precedes", etc.) + for relation_name, vector_list in tvm_chains.items(): + for vector_uuid in vector_list: + # Skip self-references, already-visited UUIDs, and duplicates + # This ensures we don't include nodes we've already processed + # (cycle protection) or the current node itself + if (vector_uuid != tvm_uuid and + vector_uuid not in visited and + vector_uuid not in direct_relations): + direct_relations.append(vector_uuid) + + # Recursively resolve chains for each directly linked TVM + all_relations: List[str] = list(direct_relations) + for related_uuid in direct_relations: + if related_uuid not in visited: + # Recurse into the related TVM's chains + nested_relations = _resolve_tvm_chains( + related_uuid, chaining_index, visited + ) + # Add any new UUIDs not already in our list + for uuid in nested_relations: + if uuid not in all_relations: + all_relations.append(uuid) + + return all_relations + + +def _resolve_dom_relations(object_data: dict) -> List[str]: + """Resolve DOM relations from the objective.threats field. + + DOM objects reference TVMs through their objective.threats field, + which contains a list of TVM UUIDs. + + Args: + object_data: The DOM object dictionary from DataTide. + + Returns: + List of TVM UUIDs from objective.threats, or empty list if absent. + """ + objective = object_data.get("objective", {}) + if not objective: + return [] + + threats = objective.get("threats", []) + if not threats: + return [] + + # Ensure we return a list of strings (UUIDs) + if isinstance(threats, list): + return [str(threat) for threat in threats if threat] + else: + # Single value case (shouldn't happen, but handle gracefully) + return [str(threats)] if threats else [] + + +def _resolve_mdr_relations(object_data: dict) -> List[str]: + """Resolve MDR relations from the detection_model field. + + MDR objects reference a single DOM through their detection_model field, + which contains the UUID of the referred DOM signal. + + Args: + object_data: The MDR object dictionary from DataTide. + + Returns: + List containing the detection_model UUID, or empty list if absent. + """ + detection_model = object_data.get("detection_model") + if not detection_model: + return [] + + # Return as a single-element list + return [str(detection_model)] diff --git a/Engines/sharing/scope.py b/Engines/sharing/scope.py new file mode 100644 index 00000000..68462984 --- /dev/null +++ b/Engines/sharing/scope.py @@ -0,0 +1,135 @@ +"""Engines/sharing/scope.py — TLP-based sharing scope computation. + +This module provides functions to compute the sharing scope for each MISP +instance based on TLP filtering. Objects are included in a scope only if +their TLP level is equal to or less restrictive than the instance's +max_allowed_tlp configuration. +""" + +from dataclasses import dataclass +from typing import Dict, List, Literal, Tuple + +from Engines.modules.logs import log +from Engines.modules.sharing import MISPInstanceConfig, TLPLevel + + +ObjectType = Literal["tvm", "dom", "mdr"] + + +@dataclass +class ScopedObject: + """Represents an OpenTIDE object that is eligible for sharing. + + This dataclass encapsulates all the information needed to process + an object for sharing with a specific MISP instance. + + Attributes: + uuid: The unique identifier of the object (metadata.uuid). + name: The human-readable name of the object. + object_type: The type of OpenTIDE object ('tvm', 'dom', or 'mdr'). + tlp: The TLP level of the object parsed from metadata.tlp. + data: The raw object dictionary from DataTide containing all fields. + """ + uuid: str + name: str + object_type: ObjectType + tlp: TLPLevel + data: dict + + +def compute_sharing_scope( + instance_config: MISPInstanceConfig, + all_objects: Dict[str, Tuple[ObjectType, dict]] +) -> List[ScopedObject]: + """Filter objects eligible for sharing with a specific MISP instance. + + This function evaluates each object against the instance's max_allowed_tlp + setting and returns only those objects that are permitted to be shared. + + For each object: + - Extract metadata.tlp (case-insensitive, white=clear normalization) + - If TLP is missing: log FAILURE, exclude from scope + - If TLP is invalid: log FAILURE, exclude from scope + - If object TLP <= instance max_allowed_tlp: include in scope + - If object TLP > instance max_allowed_tlp: log SKIP, exclude + + Args: + instance_config: Configuration for the target MISP instance, + including max_allowed_tlp setting. + all_objects: Dictionary mapping object UUIDs to tuples of + (object_type, object_data) where object_data is the + full dictionary representation from DataTide. + + Returns: + List of ScopedObject instances eligible for sharing with this + MISP instance. + + Note: + If instance_config.max_allowed_tlp is somehow invalid, this function + will log FATAL and return an empty list (skip the entire instance). + """ + # Validate instance configuration + if not isinstance(instance_config.max_allowed_tlp, TLPLevel): + log( + "FATAL", + f"Invalid max_allowed_tlp for MISP instance", + highlight=f"Instance: {instance_config.name}, " + f"Value: {instance_config.max_allowed_tlp}", + advice="Check the sharing.toml configuration for valid TLP values: " + "clear, white, green, amber, amber+strict, red" + ) + return [] + + scope: List[ScopedObject] = [] + max_tlp = instance_config.max_allowed_tlp + + for obj_uuid, (object_type, object_data) in all_objects.items(): + # Extract object name for logging + object_name = object_data.get("name", obj_uuid) + + # Extract TLP from metadata + metadata = object_data.get("metadata", {}) + tlp_value = metadata.get("tlp") + + # Handle missing TLP + if tlp_value is None: + log( + "FAILURE", + f"Object missing metadata.tlp field, excluding from all sharing scopes", + highlight=f"Object: {object_name} ({obj_uuid}), Type: {object_type}" + ) + continue + + # Parse TLP value + try: + object_tlp = TLPLevel.from_string(str(tlp_value)) + except ValueError as e: + log( + "FAILURE", + f"Object has invalid TLP value, excluding from all sharing scopes", + highlight=f"Object: {object_name} ({obj_uuid}), TLP value: {tlp_value}", + advice=str(e) + ) + continue + + # Compare TLP levels + if object_tlp <= max_tlp: + # Object is within TLP scope for this instance + scope.append(ScopedObject( + uuid=obj_uuid, + name=object_name, + object_type=object_type, + tlp=object_tlp, + data=object_data + )) + else: + # Object exceeds TLP scope for this instance + log( + "SKIP", + f"Object excluded due to TLP restrictions", + highlight=f"Object: {object_name}, TLP: {object_tlp.to_misp_tag()}, " + f"Instance: {instance_config.name}, " + f"Max allowed: {max_tlp.to_misp_tag()}" + ) + + return scope diff --git a/Engines/sharing/tagging.py b/Engines/sharing/tagging.py new file mode 100644 index 00000000..05a312fb --- /dev/null +++ b/Engines/sharing/tagging.py @@ -0,0 +1,438 @@ +"""Engines/sharing/tagging.py — Build tags and galaxy clusters for MISP events. + +This module provides functions to build MISP tags and galaxy clusters for +OpenTIDE objects being shared to MISP instances. It handles: + +- TLP tags in MISP taxonomy format (tlp:green, tlp:amber, etc.) +- ATT&CK technique tags resolved via the techniques_resolver +- Threat actor galaxy clusters for TVM objects (misp stage > att&ck stage priority) + +The tagging follows the requirements specified in the design document: +- Requirement 6.1: TLP tags in MISP taxonomy format +- Requirement 6.2: ATT&CK technique tags for resolved techniques +- Requirement 6.3: MISP Galaxy threat actor clusters for misp-stage actors +- Requirement 6.4: ATT&CK group clusters as fallback for att&ck-stage actors +- Requirement 6.5: Log FAILURE for unresolvable actors, continue processing +- Requirement 6.6: Empty technique list is not an error +""" + +from typing import List, Literal, Dict, Any, Optional + +from pymisp import MISPTag, PyMISP + +from Engines.modules.sharing import TLPLevel +from Engines.modules.framework import techniques_resolver +from Engines.modules.logs import log + + +ObjectType = Literal["tvm", "dom", "mdr"] + + +def build_tlp_tag(tlp: TLPLevel) -> MISPTag: + """Create a MISP TLP tag from the TLP level. + + Converts a TLPLevel enum value to a MISPTag object with the proper + MISP taxonomy format (e.g., 'tlp:green', 'tlp:amber+strict'). + + Args: + tlp: The TLP level enum value. + + Returns: + A MISPTag object with the name set to the TLP taxonomy tag. + + Examples: + >>> tag = build_tlp_tag(TLPLevel.GREEN) + >>> tag.name + 'tlp:green' + >>> tag = build_tlp_tag(TLPLevel.AMBER_STRICT) + >>> tag.name + 'tlp:amber+strict' + + Requirements: + - 6.1: Apply TLP tag in MISP taxonomy format + + **Validates: Requirements 6.1** + """ + tag = MISPTag() + tag.name = tlp.to_misp_tag() + return tag + + +def build_attack_tags(object_uuid: str) -> List[MISPTag]: + """Resolve ATT&CK techniques and create MISP tags for each. + + Uses the existing techniques_resolver function to resolve all ATT&CK + technique identifiers associated with an OpenTIDE object. For DOM and + MDR objects, this includes techniques from parent relationships + (MDR→DOM→TVM traversal). + + Args: + object_uuid: The UUID of the OpenTIDE object to resolve techniques for. + + Returns: + A list of MISPTag objects, one per unique resolved technique. + Returns an empty list if no techniques are resolved (not an error). + + Examples: + >>> tags = build_attack_tags("550e8400-e29b-41d4-a716-446655440000") + >>> [tag.name for tag in tags] + ['misp-galaxy:mitre-attack-pattern="T1003 - OS Credential Dumping"'] + + Behavior: + - Returns empty list if techniques_resolver returns no techniques + - Does NOT log an error for empty results (per Requirement 6.6) + - Each unique technique ID produces exactly one tag + - Uses MISP galaxy format for ATT&CK technique tags + + Requirements: + - 6.2: Apply ATT&CK technique tags for each resolved technique + - 6.6: Empty results are not an error condition + + **Validates: Requirements 6.2, 6.6** + """ + # Use the existing techniques_resolver with recursive=True + # This traverses parent relationships for DOM and MDR objects + techniques = techniques_resolver(object_uuid, recursive=True) + + # If no techniques resolved, return empty list (not an error per Req 6.6) + if not techniques: + return [] + + # Create a MISP tag for each unique technique + tags: List[MISPTag] = [] + seen_techniques = set() + + for technique_id in techniques: + # Skip duplicates (techniques_resolver already deduplicates, but be safe) + if technique_id in seen_techniques: + continue + seen_techniques.add(technique_id) + + # Create the MISP galaxy tag for ATT&CK technique + # Format: misp-galaxy:mitre-attack-pattern="TXXXX - Technique Name" + # However, we just have the technique ID, so we use a simpler format + # that MISP can match against the ATT&CK galaxy + tag = MISPTag() + # Use the standard MISP ATT&CK tag format + # This format is recognized by MISP for ATT&CK technique tagging + tag.name = f"misp-galaxy:mitre-attack-pattern=\"{technique_id}\"" + tags.append(tag) + + return tags + + +def build_actor_galaxies( + object_type: ObjectType, + object_data: dict, + misp_client: PyMISP +) -> List[dict]: + """Attach threat actor galaxy clusters to TVM events. + + Builds a list of MISP Galaxy cluster references for threat actors + associated with a TVM object. Only applies to TVM objects; returns + an empty list for DOM and MDR objects. + + Priority logic for actor resolution: + 1. If threat.actors contains entries with tide.vocab.stages == "misp": + Match by actor UUID → MISP Galaxy threat-actor cluster + 2. Elif threat.actors contains entries with tide.vocab.stages == "att&ck": + Match by ATT&CK group identifier → MISP Galaxy intrusion-set cluster + + Args: + object_type: The type of OpenTIDE object ("tvm", "dom", or "mdr"). + object_data: The full object dictionary from DataTide. + misp_client: PyMISP client for galaxy lookup on the target instance. + + Returns: + A list of galaxy cluster dictionaries suitable for attaching to + a MISP event. Returns an empty list for DOM/MDR objects or if + no actors are present. + + Behavior: + - Only processes TVM objects; returns empty for DOM/MDR + - Prioritizes misp-stage actors over att&ck-stage actors + - Logs FAILURE for unresolvable actors but continues processing + - Returns partial results if some actors resolve and others don't + + Requirements: + - 6.3: Attach MISP Galaxy threat-actor clusters for misp-stage actors + - 6.4: Attach ATT&CK group clusters for att&ck-stage actors (fallback) + - 6.5: Log FAILURE for unresolvable actors, continue processing + + **Validates: Requirements 6.3, 6.4, 6.5** + """ + # Actor galaxies only apply to TVM objects + if object_type != "tvm": + return [] + + # Extract threat.actors list from the TVM object + threat_section = object_data.get("threat", {}) + actors_list = threat_section.get("actors", []) + + if not actors_list: + return [] + + # Categorize actors by their stage + misp_stage_actors: List[dict] = [] + attack_stage_actors: List[dict] = [] + + for actor in actors_list: + if not isinstance(actor, dict): + continue + + # Get the tide.vocab.stages value + tide_section = actor.get("tide", {}) + vocab_section = tide_section.get("vocab", {}) + stages = vocab_section.get("stages", "") + + if stages == "misp": + misp_stage_actors.append(actor) + elif stages == "att&ck": + attack_stage_actors.append(actor) + + # Priority: misp-stage actors first, then att&ck-stage actors as fallback + # Per requirements 6.3 and 6.4: only use att&ck if NO misp-stage actors + if misp_stage_actors: + return _resolve_misp_actors(misp_stage_actors, misp_client, object_data) + elif attack_stage_actors: + return _resolve_attack_actors(attack_stage_actors, misp_client, object_data) + + return [] + + +def _resolve_misp_actors( + actors: List[dict], + misp_client: PyMISP, + object_data: dict +) -> List[dict]: + """Resolve MISP-stage actors to MISP Galaxy threat-actor clusters. + + Matches actors by their UUID to MISP Galaxy threat-actor clusters. + + Args: + actors: List of actor dictionaries with tide.vocab.stages == "misp". + misp_client: PyMISP client for galaxy lookup. + object_data: The full object data (for logging context). + + Returns: + List of resolved galaxy cluster dictionaries. + """ + resolved_clusters: List[dict] = [] + object_name = object_data.get("name", "Unknown") + + for actor in actors: + actor_uuid = actor.get("uuid") + actor_name = actor.get("name", "Unknown Actor") + + if not actor_uuid: + log( + "FAILURE", + f"Actor entry missing UUID in TVM object", + f"Object: {object_name}, Actor: {actor_name}", + "Skipping this actor, continuing with remaining tags." + ) + continue + + # Try to resolve the actor UUID to a MISP Galaxy threat-actor cluster + cluster = _lookup_galaxy_cluster_by_uuid( + misp_client, + actor_uuid, + galaxy_type="threat-actor" + ) + + if cluster: + resolved_clusters.append(cluster) + else: + log( + "FAILURE", + f"Cannot resolve actor to MISP Galaxy cluster", + f"Object: {object_name}, Actor: {actor_name}, UUID: {actor_uuid}", + "Skipping this actor, continuing with remaining tags." + ) + + return resolved_clusters + + +def _resolve_attack_actors( + actors: List[dict], + misp_client: PyMISP, + object_data: dict +) -> List[dict]: + """Resolve ATT&CK-stage actors to MISP Galaxy intrusion-set clusters. + + Matches actors by their ATT&CK group identifier (e.g., G0001) to + MISP Galaxy intrusion-set clusters. + + Args: + actors: List of actor dictionaries with tide.vocab.stages == "att&ck". + misp_client: PyMISP client for galaxy lookup. + object_data: The full object data (for logging context). + + Returns: + List of resolved galaxy cluster dictionaries. + """ + resolved_clusters: List[dict] = [] + object_name = object_data.get("name", "Unknown") + + for actor in actors: + actor_name = actor.get("name", "Unknown Actor") + + # For ATT&CK actors, we need an identifier (e.g., G0001) + # This could be stored in various ways; check common fields + attack_id = actor.get("id") or actor.get("attack_id") or actor.get("external_id") + + if not attack_id: + # Try to extract from name if it follows ATT&CK naming convention + # ATT&CK group IDs are typically in the format GXXXX + import re + match = re.search(r'G\d{4}', str(actor_name)) + if match: + attack_id = match.group(0) + + if not attack_id: + log( + "FAILURE", + f"Actor entry missing ATT&CK identifier in TVM object", + f"Object: {object_name}, Actor: {actor_name}", + "Skipping this actor, continuing with remaining tags." + ) + continue + + # Try to resolve the ATT&CK group ID to a MISP Galaxy intrusion-set cluster + cluster = _lookup_galaxy_cluster_by_value( + misp_client, + attack_id, + galaxy_type="mitre-intrusion-set" + ) + + if cluster: + resolved_clusters.append(cluster) + else: + log( + "FAILURE", + f"Cannot resolve ATT&CK actor to MISP Galaxy cluster", + f"Object: {object_name}, Actor: {actor_name}, ID: {attack_id}", + "Skipping this actor, continuing with remaining tags." + ) + + return resolved_clusters + + +def _lookup_galaxy_cluster_by_uuid( + misp_client: PyMISP, + cluster_uuid: str, + galaxy_type: str +) -> Optional[dict]: + """Look up a MISP Galaxy cluster by its UUID. + + Queries the MISP instance to find a galaxy cluster matching the + specified UUID and galaxy type. + + Args: + misp_client: PyMISP client for the target MISP instance. + cluster_uuid: The UUID of the galaxy cluster to find. + galaxy_type: The type of galaxy to search (e.g., "threat-actor"). + + Returns: + A dictionary representing the galaxy cluster if found, None otherwise. + """ + try: + # Use PyMISP's galaxy cluster search + result = misp_client.search_galaxy_clusters( + galaxy=galaxy_type, + uuid=cluster_uuid, + pythonify=False + ) + + if result and isinstance(result, list) and len(result) > 0: + # Return the first matching cluster + cluster_data = result[0] + # Return a format suitable for attaching to events + return _format_cluster_for_attachment(cluster_data) + + return None + + except Exception as e: + # Log at debug level - the caller will log FAILURE for unresolved actors + return None + + +def _lookup_galaxy_cluster_by_value( + misp_client: PyMISP, + search_value: str, + galaxy_type: str +) -> Optional[dict]: + """Look up a MISP Galaxy cluster by a value (e.g., ATT&CK ID). + + Queries the MISP instance to find a galaxy cluster matching the + specified value and galaxy type. + + Args: + misp_client: PyMISP client for the target MISP instance. + search_value: The value to search for (e.g., "G0001"). + galaxy_type: The type of galaxy to search (e.g., "mitre-intrusion-set"). + + Returns: + A dictionary representing the galaxy cluster if found, None otherwise. + """ + try: + # Use PyMISP's galaxy cluster search with searchall + result = misp_client.search_galaxy_clusters( + galaxy=galaxy_type, + searchall=search_value, + pythonify=False + ) + + if result and isinstance(result, list) and len(result) > 0: + # Find the best match - prefer exact ID match + for cluster in result: + cluster_meta = cluster.get("GalaxyCluster", cluster) + # Check if this cluster's value or any synonym matches + cluster_value = cluster_meta.get("value", "") + if search_value.lower() in cluster_value.lower(): + return _format_cluster_for_attachment(cluster) + + # Check meta.external_id if available + meta = cluster_meta.get("meta", {}) + external_ids = meta.get("external_id", []) + if isinstance(external_ids, list) and search_value in external_ids: + return _format_cluster_for_attachment(cluster) + elif external_ids == search_value: + return _format_cluster_for_attachment(cluster) + + # If no exact match, return the first result + return _format_cluster_for_attachment(result[0]) + + return None + + except Exception as e: + # Log at debug level - the caller will log FAILURE for unresolved actors + return None + + +def _format_cluster_for_attachment(cluster_data: dict) -> dict: + """Format a galaxy cluster for attachment to a MISP event. + + Extracts the necessary fields from a MISP API response to create + a cluster reference that can be attached to events. + + Args: + cluster_data: Raw cluster data from the MISP API. + + Returns: + A formatted dictionary suitable for event attachment. + """ + # Handle both wrapped and unwrapped responses + if "GalaxyCluster" in cluster_data: + cluster = cluster_data["GalaxyCluster"] + else: + cluster = cluster_data + + return { + "uuid": cluster.get("uuid"), + "type": cluster.get("type"), + "value": cluster.get("value"), + "tag_name": cluster.get("tag_name"), + "galaxy_id": cluster.get("galaxy_id"), + "collection_uuid": cluster.get("collection_uuid"), + } diff --git a/Engines/sharing/tests/__init__.py b/Engines/sharing/tests/__init__.py new file mode 100644 index 00000000..c02237b0 --- /dev/null +++ b/Engines/sharing/tests/__init__.py @@ -0,0 +1 @@ +# Sharing module tests package diff --git a/Engines/sharing/tests/test_config_parsing_properties.py b/Engines/sharing/tests/test_config_parsing_properties.py new file mode 100644 index 00000000..7eef1f89 --- /dev/null +++ b/Engines/sharing/tests/test_config_parsing_properties.py @@ -0,0 +1,919 @@ +"""Property-based tests for configuration parsing. + +This module tests Properties 1, 2, and 3 from the MISP Sharing Pipeline design document: +- Property 1: Configuration round-trip integrity +- Property 2: Invalid configuration rejection +- Property 3: Environment variable resolution + +**Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.7, 1.11** +""" + +import os +import sys +import uuid +from pathlib import Path +from typing import Any, Dict, List +from unittest.mock import MagicMock, patch + +import git +import pytest +from hypothesis import given, settings, assume, example +from hypothesis import strategies as st + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + + +# ============================================================================ +# Mock Setup +# ============================================================================ + +class MockHelperTide: + """Mock HelperTide for tests that need environment variable resolution.""" + + @staticmethod + def is_debug(): + return True + + @staticmethod + def fetch_config_envvar(config_secrets: dict) -> dict: + """Resolve $-prefixed environment variables from os.environ. + + This mock mirrors the real HelperTide.fetch_config_envvar behavior: + - Values starting with $ are resolved from os.environ + - If the env var exists, the value is replaced + - If the env var doesn't exist, the value remains unchanged (with $ prefix) + """ + result = dict(config_secrets) + for key in list(result.keys()): + if isinstance(result[key], str) and result[key].startswith('$'): + env_var_name = result[key][1:] + if env_var_name in os.environ: + result[key] = os.environ[env_var_name] + return result + + +# Mock the tide module before importing sharing to avoid DataTide initialization +mock_tide = MagicMock() +mock_tide.HelperTide = MockHelperTide +mock_tide.DataTide = MagicMock() +sys.modules['Engines.modules.tide'] = mock_tide + +# Mock the logs module +mock_logs = MagicMock() +mock_logs.log = MagicMock() +sys.modules['Engines.modules.logs'] = mock_logs + + +# ============================================================================ +# Import sharing module after mocking dependencies +# ============================================================================ + +# Import the sharing module - use importlib to ensure fresh import +import importlib + +# If the module was already imported, reload it with the mocks in place +if 'Engines.modules.sharing' in sys.modules: + # Remove and reimport + del sys.modules['Engines.modules.sharing'] + +from Engines.modules.sharing import ( + TLPLevel, + MISPInstanceConfig, + OrganisationConfig, + SharingConfig, + load_sharing_config, +) + + +# ============================================================================ +# Test Data Strategies +# ============================================================================ + +# Valid TLP strings (case-insensitive in implementation) +VALID_TLP_STRINGS = ["clear", "white", "green", "amber", "amber+strict", "red"] +VALID_MODES = ["send", "fetch", "sync"] + + +@st.composite +def valid_uuid_v4(draw) -> str: + """Generate a valid UUIDv4 string.""" + return str(uuid.uuid4()) + + +@st.composite +def valid_org_name(draw) -> str: + """Generate a valid organisation name (1-256 chars).""" + return draw(st.text( + min_size=1, + max_size=256, + alphabet=st.characters( + whitelist_categories=("Lu", "Ll", "Nd", "Pc"), + whitelist_characters=" -_." + ) + )) + + +@st.composite +def valid_instance_name(draw) -> str: + """Generate a valid MISP instance name (1-128 chars).""" + return draw(st.text( + min_size=1, + max_size=128, + alphabet=st.characters( + whitelist_categories=("Lu", "Ll", "Nd", "Pc"), + whitelist_characters=" -_." + ) + )) + + +@st.composite +def valid_url(draw) -> str: + """Generate a valid HTTP/HTTPS URL.""" + protocol = draw(st.sampled_from(["http", "https"])) + domain = draw(st.text( + min_size=1, + max_size=50, + alphabet=st.characters(whitelist_categories=("Ll", "Nd"), whitelist_characters="-") + )) + tld = draw(st.sampled_from(["org", "com", "net", "io"])) + return f"{protocol}://{domain}.{tld}" + + +@st.composite +def valid_organisation_config(draw) -> Dict[str, Any]: + """Generate a valid [organisation] section dictionary.""" + return { + "enabled": draw(st.booleans()), + "name": draw(valid_org_name()), + "uuid": draw(valid_uuid_v4()) + } + + +@st.composite +def valid_misp_instance_dict(draw, use_env_token: bool = False) -> Dict[str, Any]: + """Generate a valid [[misp]] entry dictionary. + + Args: + use_env_token: If True, token will be $ENV_VAR format instead of literal. + """ + if use_env_token: + token = "$" + draw(st.text( + min_size=1, + max_size=30, + alphabet=st.characters(whitelist_categories=("Lu", "Nd"), whitelist_characters="_") + )) + else: + token = draw(st.text(min_size=1, max_size=100, alphabet=st.characters( + whitelist_categories=("Lu", "Ll", "Nd") + ))) + + return { + "name": draw(valid_instance_name()), + "url": draw(valid_url()), + "token": token, + "org_uuid": draw(valid_uuid_v4()), + "max_allowed_tlp": draw(st.sampled_from(VALID_TLP_STRINGS)), + "mode": draw(st.sampled_from(VALID_MODES)), + "proxy": draw(st.booleans()), + "publish_on_change": draw(st.booleans()), + "verify_ssl": draw(st.booleans()) + } + + +@st.composite +def valid_sharing_toml(draw, num_instances: int = None) -> Dict[str, Any]: + """Generate a complete valid sharing.toml content dictionary. + + Args: + num_instances: Specific number of MISP instances to generate. + If None, a random number (0-5) is generated. + """ + if num_instances is None: + num_instances = draw(st.integers(min_value=0, max_value=5)) + + org_config = draw(valid_organisation_config()) + + # Ensure organisation has valid name and uuid when enabled + if org_config["enabled"]: + # Force valid values for enabled organisation + if not org_config["name"]: + org_config["name"] = "Default Organisation" + + instances = [draw(valid_misp_instance_dict()) for _ in range(num_instances)] + + # Ensure proxy consistency - if any instance has proxy=true, + # we need to account for this in tests + + return { + "organisation": org_config, + "misp": instances + } + + +@st.composite +def valid_deployment_toml(draw, proxy_enabled: bool = None) -> Dict[str, Any]: + """Generate a deployment.toml content dictionary. + + Args: + proxy_enabled: Specific value for proxy_enabled. + If None, randomly generated. + """ + if proxy_enabled is None: + proxy_enabled = draw(st.booleans()) + + return { + "debug": { + "proxy_enabled": proxy_enabled + } + } + + +# ============================================================================ +# Property 1: Configuration round-trip integrity +# ============================================================================ + +class TestConfigurationRoundTripIntegrity: + """Property 1: Configuration round-trip integrity. + + **Validates: Requirements 1.1, 1.2** + + Property Statement: + *For any* valid `sharing.toml` content containing a well-formed `[organisation]` + section and zero or more valid `[[misp]]` entries, parsing via `load_sharing_config()` + SHALL produce a `SharingConfig` dataclass whose fields, when serialized back to a + dictionary, are equivalent to the original input (after env var resolution and + TLP normalization). + """ + + @given(st.data()) + @settings(max_examples=100) + def test_valid_config_parses_to_equivalent_dataclass(self, data): + """Test that valid configs parse successfully and preserve field values. + + **Validates: Requirements 1.1, 1.2** + """ + # Generate valid sharing.toml with instances that don't require proxy + sharing_toml = data.draw(valid_sharing_toml()) + + # To avoid proxy consistency errors, either: + # 1. Disable proxy on all instances, OR + # 2. Enable proxy_enabled globally + proxy_enabled = any( + inst.get("proxy", False) + for inst in sharing_toml.get("misp", []) + ) + deployment_toml = data.draw(valid_deployment_toml(proxy_enabled=proxy_enabled)) + + # Parse the configuration + config = load_sharing_config(sharing_toml, deployment_toml) + + # Verify organisation fields match + org = config.organisation + assert org.enabled == sharing_toml["organisation"]["enabled"] + assert org.name == sharing_toml["organisation"]["name"] + assert org.uuid == sharing_toml["organisation"]["uuid"] + + # Verify number of instances matches + assert len(config.instances) == len(sharing_toml.get("misp", [])) + + # Verify each instance's fields match (accounting for TLP normalization) + for i, instance in enumerate(config.instances): + orig = sharing_toml["misp"][i] + + assert instance.name == orig["name"] + assert instance.url == orig["url"] + # Token may be resolved from env var, so we check it was preserved or resolved + if not orig["token"].startswith("$"): + assert instance.token == orig["token"] + assert instance.org_uuid == orig["org_uuid"] + # TLP is normalized to TLPLevel enum + expected_tlp = TLPLevel.from_string(orig["max_allowed_tlp"]) + assert instance.max_allowed_tlp == expected_tlp + assert instance.mode == orig["mode"] + assert instance.proxy == orig["proxy"] + assert instance.publish_on_change == orig["publish_on_change"] + assert instance.verify_ssl == orig["verify_ssl"] + + @given( + enabled=st.booleans(), + name=valid_org_name(), + org_uuid=valid_uuid_v4() + ) + @settings(max_examples=50) + def test_organisation_section_fields_preserved(self, enabled, name, org_uuid): + """Test that organisation section fields are correctly preserved. + + **Validates: Requirements 1.1** + """ + sharing_toml = { + "organisation": { + "enabled": enabled, + "name": name, + "uuid": org_uuid + }, + "misp": [] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + config = load_sharing_config(sharing_toml, deployment_toml) + + assert config.organisation.enabled == enabled + assert config.organisation.name == name + assert config.organisation.uuid == org_uuid + + @given(instance_data=valid_misp_instance_dict(use_env_token=False)) + @settings(max_examples=50) + def test_misp_instance_fields_preserved(self, instance_data): + """Test that MISP instance fields are correctly preserved. + + **Validates: Requirements 1.2** + """ + # Ensure proxy consistency + proxy_enabled = instance_data.get("proxy", False) + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [instance_data] + } + deployment_toml = {"debug": {"proxy_enabled": proxy_enabled}} + + config = load_sharing_config(sharing_toml, deployment_toml) + + assert len(config.instances) == 1 + instance = config.instances[0] + + assert instance.name == instance_data["name"] + assert instance.url == instance_data["url"] + assert instance.token == instance_data["token"] + assert instance.org_uuid == instance_data["org_uuid"] + assert instance.max_allowed_tlp == TLPLevel.from_string(instance_data["max_allowed_tlp"]) + assert instance.mode == instance_data["mode"] + assert instance.proxy == instance_data["proxy"] + assert instance.publish_on_change == instance_data["publish_on_change"] + assert instance.verify_ssl == instance_data["verify_ssl"] + + @given(num_instances=st.integers(min_value=0, max_value=10)) + @settings(max_examples=30) + def test_multiple_instances_all_parsed(self, num_instances): + """Test that all MISP instances are parsed correctly. + + **Validates: Requirements 1.2** + """ + instances = [] + for i in range(num_instances): + instances.append({ + "name": f"Instance {i}", + "url": f"https://misp{i}.example.org", + "token": f"token{i}", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }) + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": instances + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + config = load_sharing_config(sharing_toml, deployment_toml) + + assert len(config.instances) == num_instances + for i, instance in enumerate(config.instances): + assert instance.name == f"Instance {i}" + + +# ============================================================================ +# Property 2: Invalid configuration rejection +# ============================================================================ + +class TestInvalidConfigurationRejection: + """Property 2: Invalid configuration rejection. + + **Validates: Requirements 1.7, 1.11** + + Property Statement: + *For any* `sharing.toml` content containing at least one field that violates + its type constraint (non-boolean where boolean expected, string exceeding length + limit, invalid UUID format, invalid URL, mode not in {send, fetch, sync}), + `load_sharing_config()` SHALL raise an error and the resulting SharingConfig + SHALL NOT be successfully constructed. + """ + + @given(invalid_enabled=st.one_of( + st.integers(), + st.text(min_size=1), + st.floats(), + st.none() + )) + @settings(max_examples=50) + def test_non_boolean_enabled_rejected(self, invalid_enabled): + """Test that non-boolean organisation.enabled is rejected. + + **Validates: Requirements 1.11** + """ + sharing_toml = { + "organisation": { + "enabled": invalid_enabled, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + @given(name_length=st.integers(min_value=257, max_value=500)) + @settings(max_examples=30) + def test_org_name_exceeding_256_chars_rejected(self, name_length): + """Test that organisation.name exceeding 256 characters is rejected. + + **Validates: Requirements 1.1, 1.11** + """ + long_name = "x" * name_length + + sharing_toml = { + "organisation": { + "enabled": True, + "name": long_name, + "uuid": str(uuid.uuid4()) + }, + "misp": [] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + @given(invalid_uuid=st.text(min_size=1, max_size=50).filter( + lambda s: not _is_valid_uuid_v4_format(s) + )) + @settings(max_examples=50) + def test_invalid_org_uuid_format_rejected(self, invalid_uuid): + """Test that invalid organisation.uuid format is rejected. + + **Validates: Requirements 1.1, 1.11** + """ + assume(invalid_uuid.strip()) # Ensure non-empty + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": invalid_uuid + }, + "misp": [] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + @given(name_length=st.integers(min_value=129, max_value=300)) + @settings(max_examples=30) + def test_instance_name_exceeding_128_chars_rejected(self, name_length): + """Test that MISP instance name exceeding 128 characters is rejected. + + **Validates: Requirements 1.2, 1.11** + """ + long_name = "x" * name_length + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [{ + "name": long_name, + "url": "https://misp.example.org", + "token": "test-token", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + @given(invalid_url=st.one_of( + st.just("not-a-url"), + st.just("ftp://invalid-protocol.org"), + st.just("missing-protocol.org"), + st.just(""), + st.text(min_size=1, max_size=20).filter(lambda s: not s.startswith("http")) + )) + @settings(max_examples=50) + def test_invalid_url_rejected(self, invalid_url): + """Test that invalid MISP instance URL is rejected. + + **Validates: Requirements 1.2, 1.11** + """ + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [{ + "name": "Test Instance", + "url": invalid_url, + "token": "test-token", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + @given(invalid_mode=st.text(min_size=1, max_size=20).filter( + lambda s: s.lower() not in VALID_MODES + )) + @settings(max_examples=50) + def test_invalid_mode_rejected(self, invalid_mode): + """Test that invalid MISP instance mode is rejected. + + **Validates: Requirements 1.7, 1.11** + """ + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [{ + "name": "Test Instance", + "url": "https://misp.example.org", + "token": "test-token", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": invalid_mode, + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + @given(invalid_tlp=st.text(min_size=1, max_size=30).filter( + lambda s: s.lower().strip() not in [t.lower() for t in VALID_TLP_STRINGS] + )) + @settings(max_examples=50) + def test_invalid_max_allowed_tlp_rejected(self, invalid_tlp): + """Test that invalid max_allowed_tlp value is rejected. + + **Validates: Requirements 1.11** + """ + assume(invalid_tlp.strip()) # Ensure non-empty + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [{ + "name": "Test Instance", + "url": "https://misp.example.org", + "token": "test-token", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": invalid_tlp, + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + @given(invalid_boolean_field=st.sampled_from(["proxy", "publish_on_change", "verify_ssl"])) + @settings(max_examples=30) + def test_non_boolean_instance_fields_rejected(self, invalid_boolean_field): + """Test that non-boolean values for boolean fields are rejected. + + **Validates: Requirements 1.2, 1.11** + """ + instance_data = { + "name": "Test Instance", + "url": "https://misp.example.org", + "token": "test-token", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + } + + # Set the target field to an invalid non-boolean value + instance_data[invalid_boolean_field] = "not-a-boolean" + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [instance_data] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + def test_proxy_without_global_proxy_enabled_rejected(self): + """Test that proxy=true without global proxy_enabled=true is rejected. + + **Validates: Requirements 1.11** (proxy consistency) + """ + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [{ + "name": "Test Instance", + "url": "https://misp.example.org", + "token": "test-token", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": True, # Requires proxy_enabled=true globally + "publish_on_change": True, + "verify_ssl": True + }] + } + # proxy_enabled is False, so this should fail + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + def test_missing_organisation_section_rejected(self): + """Test that missing [organisation] section is rejected. + + **Validates: Requirements 1.1** + """ + sharing_toml = { + "misp": [] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError): + load_sharing_config(sharing_toml, deployment_toml) + + +# ============================================================================ +# Property 3: Environment variable resolution +# ============================================================================ + +class TestEnvironmentVariableResolution: + """Property 3: Environment variable resolution. + + **Validates: Requirements 1.3, 1.4** + + Property Statement: + *For any* token string starting with `$` where the referenced environment variable + exists, `load_sharing_config()` SHALL replace the token with the environment + variable value. *For any* token string starting with `$` where the referenced + environment variable does NOT exist, the function SHALL signal a fatal error. + """ + + @given( + env_var_name=st.text( + min_size=1, + max_size=30, + alphabet=st.characters(whitelist_categories=("Lu", "Nd"), whitelist_characters="_") + ).filter(lambda s: s and s[0].isalpha()), + env_var_value=st.text(min_size=1, max_size=100, alphabet=st.characters( + whitelist_categories=("Lu", "Ll", "Nd") + )) + ) + @settings(max_examples=50) + def test_existing_env_var_resolved(self, env_var_name, env_var_value): + """Test that $-prefixed tokens resolve from existing environment variables. + + **Validates: Requirements 1.3** + """ + # Set the environment variable + original_value = os.environ.get(env_var_name) + os.environ[env_var_name] = env_var_value + + try: + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [{ + "name": "Test Instance", + "url": "https://misp.example.org", + "token": f"${env_var_name}", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + config = load_sharing_config(sharing_toml, deployment_toml) + + # The token should be resolved to the environment variable value + assert config.instances[0].token == env_var_value + finally: + # Restore original environment + if original_value is None: + os.environ.pop(env_var_name, None) + else: + os.environ[env_var_name] = original_value + + @given( + env_var_name=st.text( + min_size=1, + max_size=30, + alphabet=st.characters(whitelist_categories=("Lu", "Nd"), whitelist_characters="_") + ).filter(lambda s: s and s[0].isalpha()) + ) + @settings(max_examples=50) + def test_missing_env_var_raises_error(self, env_var_name): + """Test that $-prefixed tokens with missing env vars raise an error. + + **Validates: Requirements 1.4** + """ + # Ensure the environment variable does NOT exist + assume(env_var_name not in os.environ) + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [{ + "name": "Test Instance", + "url": "https://misp.example.org", + "token": f"${env_var_name}", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + with pytest.raises(ValueError) as exc_info: + load_sharing_config(sharing_toml, deployment_toml) + + # Verify the error message mentions the missing variable + assert env_var_name in str(exc_info.value) + + def test_non_prefixed_token_preserved(self): + """Test that tokens NOT starting with $ are preserved as-is. + + **Validates: Requirements 1.3** (inverse case) + """ + literal_token = "my-api-token-12345" + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": [{ + "name": "Test Instance", + "url": "https://misp.example.org", + "token": literal_token, + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }] + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + config = load_sharing_config(sharing_toml, deployment_toml) + + # The literal token should be preserved exactly + assert config.instances[0].token == literal_token + + @given( + env_var_names=st.lists( + st.text( + min_size=1, + max_size=20, + alphabet=st.characters(whitelist_categories=("Lu", "Nd"), whitelist_characters="_") + ).filter(lambda s: s and s[0].isalpha()), + min_size=1, + max_size=5, + unique=True + ) + ) + @settings(max_examples=30) + def test_multiple_instances_with_env_vars(self, env_var_names): + """Test that multiple instances with different env vars are resolved correctly. + + **Validates: Requirements 1.3** + """ + # Set environment variables + original_values = {} + for name in env_var_names: + original_values[name] = os.environ.get(name) + os.environ[name] = f"value_for_{name}" + + try: + instances = [] + for i, var_name in enumerate(env_var_names): + instances.append({ + "name": f"Instance {i}", + "url": f"https://misp{i}.example.org", + "token": f"${var_name}", + "org_uuid": str(uuid.uuid4()), + "max_allowed_tlp": "green", + "mode": "send", + "proxy": False, + "publish_on_change": True, + "verify_ssl": True + }) + + sharing_toml = { + "organisation": { + "enabled": True, + "name": "Test Org", + "uuid": str(uuid.uuid4()) + }, + "misp": instances + } + deployment_toml = {"debug": {"proxy_enabled": False}} + + config = load_sharing_config(sharing_toml, deployment_toml) + + # Verify each instance's token was resolved correctly + for i, var_name in enumerate(env_var_names): + assert config.instances[i].token == f"value_for_{var_name}" + finally: + # Restore original environment + for name in env_var_names: + if original_values[name] is None: + os.environ.pop(name, None) + else: + os.environ[name] = original_values[name] + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def _is_valid_uuid_v4_format(value: str) -> bool: + """Check if a string is a valid UUIDv4 format.""" + import re + pattern = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + re.IGNORECASE + ) + return bool(pattern.match(value)) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/Engines/sharing/tests/test_connector.py b/Engines/sharing/tests/test_connector.py new file mode 100644 index 00000000..baeea09f --- /dev/null +++ b/Engines/sharing/tests/test_connector.py @@ -0,0 +1,886 @@ +"""Unit tests for the MISP connector module. + +**Validates: Requirements 9.1, 9.2, 9.3, 9.4, 9.5, 9.6** + +This module contains unit tests for: +- PyMISP initialization with correct parameters +- SSL verification enabled/disabled +- Proxy configuration with and without credentials +- Connection failure handling and FATAL logging +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, PropertyMock +import socket +import ssl + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + + +# ============================================================================ +# Mock Setup - must be done BEFORE any imports that trigger DataTide +# ============================================================================ + +class MockHelperTide: + """Mock HelperTide for tests.""" + + @staticmethod + def is_debug(): + return True + + @staticmethod + def fetch_config_envvar(config_secrets: dict) -> dict: + """Mock environment variable resolution.""" + return dict(config_secrets) + + +# Mock the tide module before importing sharing to avoid DataTide initialization +mock_tide = MagicMock() +mock_tide.HelperTide = MockHelperTide +mock_tide.DataTide = MagicMock() +sys.modules['Engines.modules.tide'] = mock_tide + +# Mock the logs module +mock_logs = MagicMock() +mock_logs.log = MagicMock() +sys.modules['Engines.modules.logs'] = mock_logs + +import pytest + +# Remove cached module if present to ensure fresh import with mocks +if 'Engines.modules.sharing' in sys.modules: + del sys.modules['Engines.modules.sharing'] +if 'Engines.sharing.connector' in sys.modules: + del sys.modules['Engines.sharing.connector'] + +from Engines.modules.sharing import MISPInstanceConfig, TLPLevel +from Engines.sharing.connector import ( + create_misp_client, + _build_proxy_dict, + CONNECTION_TIMEOUT, +) + + +def _create_instance_config( + name: str = "Test MISP", + url: str = "https://misp.test.org", + token: str = "test-api-token", + org_uuid: str = "11111111-2222-3333-4444-555555555555", + max_allowed_tlp: TLPLevel = TLPLevel.AMBER, + mode: str = "send", + proxy: bool = False, + publish_on_change: bool = True, + verify_ssl: bool = True +) -> MISPInstanceConfig: + """Helper function to create MISPInstanceConfig for tests.""" + return MISPInstanceConfig( + name=name, + url=url, + token=token, + org_uuid=org_uuid, + max_allowed_tlp=max_allowed_tlp, + mode=mode, + proxy=proxy, + publish_on_change=publish_on_change, + verify_ssl=verify_ssl + ) + + +class TestPyMISPInitialization: + """Tests for PyMISP initialization with correct parameters. + + **Validates: Requirements 9.1, 9.2** + """ + + @patch('Engines.sharing.connector.PyMISP') + def test_pymisp_initialized_with_correct_url(self, mock_pymisp_class): + """Test that PyMISP is initialized with the configured URL. + + **Validates: Requirements 9.1, 9.2** + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(url="https://misp.example.org") + + result = create_misp_client(config) + + mock_pymisp_class.assert_called_once() + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['url'] == "https://misp.example.org" + assert result is mock_client + + @patch('Engines.sharing.connector.PyMISP') + def test_pymisp_initialized_with_correct_token(self, mock_pymisp_class): + """Test that PyMISP is initialized with the configured API token. + + **Validates: Requirements 9.1, 9.2** + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(token="my-secret-api-key-12345") + + create_misp_client(config) + + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['key'] == "my-secret-api-key-12345" + + @patch('Engines.sharing.connector.PyMISP') + def test_pymisp_initialized_with_timeout(self, mock_pymisp_class): + """Test that PyMISP is initialized with the correct timeout value. + + **Validates: Requirements 9.1, 9.2** + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config() + + create_misp_client(config) + + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['timeout'] == CONNECTION_TIMEOUT + assert call_kwargs['timeout'] == 30 # Per requirements + + +class TestSSLVerification: + """Tests for SSL verification enabled/disabled. + + **Validates: Requirements 9.1, 9.2** + """ + + @patch('Engines.sharing.connector.PyMISP') + def test_ssl_verification_enabled_when_verify_ssl_true(self, mock_pymisp_class): + """Test that SSL verification is enabled when verify_ssl is True. + + **Validates: Requirements 9.1** + + WHEN verify_ssl is set to true for a MISP_Instance, THE Sharing_Engine + SHALL validate the server TLS certificate during API communication. + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(verify_ssl=True) + + create_misp_client(config) + + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['ssl'] is True + + @patch('Engines.sharing.connector.PyMISP') + def test_ssl_verification_disabled_when_verify_ssl_false(self, mock_pymisp_class): + """Test that SSL verification is disabled when verify_ssl is False. + + **Validates: Requirements 9.2** + + WHEN verify_ssl is set to false for a MISP_Instance, THE Sharing_Engine + SHALL skip TLS certificate validation for that instance. + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(verify_ssl=False) + + create_misp_client(config) + + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['ssl'] is False + + +class TestProxyConfiguration: + """Tests for proxy configuration with and without credentials. + + **Validates: Requirements 9.3, 9.4** + """ + + @patch('Engines.sharing.connector.PyMISP') + def test_no_proxy_when_proxy_disabled(self, mock_pymisp_class): + """Test that no proxy is configured when proxy is disabled. + + **Validates: Requirements 9.3** + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(proxy=False) + + create_misp_client(config) + + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['proxies'] is None + + @patch('Engines.sharing.connector.PyMISP') + def test_proxy_configured_when_enabled(self, mock_pymisp_class): + """Test that proxy is configured when enabled. + + **Validates: Requirements 9.3** + + WHERE proxy configuration is enabled for a MISP_Instance, THE Sharing_Engine + SHALL configure the PyMISP connection to route through the proxy host and port + defined in the [proxy] section of deployment.toml. + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(proxy=True) + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080" + } + + create_misp_client(config, proxy_config) + + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['proxies'] is not None + assert 'http' in call_kwargs['proxies'] + assert 'https' in call_kwargs['proxies'] + assert "proxy.example.org:8080" in call_kwargs['proxies']['http'] + assert "proxy.example.org:8080" in call_kwargs['proxies']['https'] + + @patch('Engines.sharing.connector.PyMISP') + def test_proxy_without_auth_credentials(self, mock_pymisp_class): + """Test proxy configuration without authentication credentials. + + **Validates: Requirements 9.3** + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(proxy=True) + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "3128" + } + + create_misp_client(config, proxy_config) + + call_kwargs = mock_pymisp_class.call_args[1] + expected_proxy_url = "http://proxy.example.org:3128" + assert call_kwargs['proxies']['http'] == expected_proxy_url + assert call_kwargs['proxies']['https'] == expected_proxy_url + + @patch('Engines.sharing.connector.PyMISP') + def test_proxy_with_auth_credentials(self, mock_pymisp_class): + """Test proxy configuration with authentication credentials. + + **Validates: Requirements 9.4** + + WHERE proxy configuration is enabled and both proxy_user and proxy_password + in the [proxy] section resolve to non-empty values after environment variable + substitution, THE Sharing_Engine SHALL include the proxy username and password + in the proxy configuration. + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(proxy=True) + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080", + "proxy_user": "proxyuser", + "proxy_password": "proxypass123" + } + + create_misp_client(config, proxy_config) + + call_kwargs = mock_pymisp_class.call_args[1] + expected_proxy_url = "http://proxyuser:proxypass123@proxy.example.org:8080" + assert call_kwargs['proxies']['http'] == expected_proxy_url + assert call_kwargs['proxies']['https'] == expected_proxy_url + + @patch('Engines.sharing.connector.PyMISP') + def test_proxy_with_empty_credentials_not_included(self, mock_pymisp_class): + """Test that empty credentials are not included in proxy URL. + + **Validates: Requirements 9.4** + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(proxy=True) + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080", + "proxy_user": "", + "proxy_password": "" + } + + create_misp_client(config, proxy_config) + + call_kwargs = mock_pymisp_class.call_args[1] + # Should not include credentials when empty + expected_proxy_url = "http://proxy.example.org:8080" + assert call_kwargs['proxies']['http'] == expected_proxy_url + assert "@" not in call_kwargs['proxies']['http'] + + @patch('Engines.sharing.connector.PyMISP') + def test_proxy_with_only_user_not_included(self, mock_pymisp_class): + """Test that credentials are only included when both user and password are present. + + **Validates: Requirements 9.4** + """ + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(proxy=True) + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080", + "proxy_user": "proxyuser", + "proxy_password": "" # Empty password + } + + create_misp_client(config, proxy_config) + + call_kwargs = mock_pymisp_class.call_args[1] + # Should not include credentials when password is empty + expected_proxy_url = "http://proxy.example.org:8080" + assert call_kwargs['proxies']['http'] == expected_proxy_url + assert "@" not in call_kwargs['proxies']['http'] + + +class TestBuildProxyDict: + """Tests for the _build_proxy_dict helper function. + + **Validates: Requirements 9.3, 9.4** + """ + + def test_returns_none_when_proxy_host_missing(self): + """Test that None is returned when proxy_host is missing.""" + proxy_config = {"proxy_port": "8080"} + + result = _build_proxy_dict(proxy_config, "Test Instance") + + assert result is None + + def test_returns_none_when_proxy_port_missing(self): + """Test that None is returned when proxy_port is missing.""" + proxy_config = {"proxy_host": "proxy.example.org"} + + result = _build_proxy_dict(proxy_config, "Test Instance") + + assert result is None + + def test_returns_proxy_dict_with_valid_config(self): + """Test that proxy dict is returned with valid configuration.""" + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080" + } + + result = _build_proxy_dict(proxy_config, "Test Instance") + + assert result is not None + assert 'http' in result + assert 'https' in result + assert result['http'] == "http://proxy.example.org:8080" + assert result['https'] == "http://proxy.example.org:8080" + + def test_includes_auth_when_both_user_and_password_present(self): + """Test that auth is included when both user and password are present.""" + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080", + "proxy_user": "user", + "proxy_password": "pass" + } + + result = _build_proxy_dict(proxy_config, "Test Instance") + + assert result['http'] == "http://user:pass@proxy.example.org:8080" + assert result['https'] == "http://user:pass@proxy.example.org:8080" + + def test_excludes_auth_when_user_missing(self): + """Test that auth is excluded when user is missing.""" + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080", + "proxy_password": "pass" + } + + result = _build_proxy_dict(proxy_config, "Test Instance") + + assert result['http'] == "http://proxy.example.org:8080" + assert "@" not in result['http'] + + def test_excludes_auth_when_password_missing(self): + """Test that auth is excluded when password is missing.""" + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080", + "proxy_user": "user" + } + + result = _build_proxy_dict(proxy_config, "Test Instance") + + assert result['http'] == "http://proxy.example.org:8080" + assert "@" not in result['http'] + + +class TestConnectionFailureHandling: + """Tests for connection failure handling and FATAL logging. + + **Validates: Requirements 9.5, 9.6** + """ + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_on_ssl_error(self, mock_pymisp_class): + """Test that None is returned on SSL certificate validation failure. + + **Validates: Requirements 9.5** + + IF TLS certificate validation fails when verify_ssl is true, THEN + THE Sharing_Engine SHALL log a FATAL message identifying the instance + name and the certificate error, and skip all object sharing for that instance. + """ + mock_pymisp_class.side_effect = ssl.SSLError("certificate verify failed") + + config = _create_instance_config(name="SSL Test MISP", verify_ssl=True) + + result = create_misp_client(config) + + assert result is None + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_fatal_on_ssl_error(self, mock_pymisp_class): + """Test that FATAL is logged on SSL certificate validation failure. + + **Validates: Requirements 9.5** + """ + mock_pymisp_class.side_effect = ssl.SSLError("certificate verify failed") + mock_logs.log.reset_mock() + + config = _create_instance_config(name="SSL Test MISP", verify_ssl=True) + + create_misp_client(config) + + # Verify FATAL was logged with the instance name + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "SSL Test MISP" in str(call_args) + assert "SSL" in str(call_args) or "certificate" in str(call_args).lower() + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_on_timeout(self, mock_pymisp_class): + """Test that None is returned on connection timeout. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = socket.timeout("timed out") + + config = _create_instance_config(name="Timeout MISP") + + result = create_misp_client(config) + + assert result is None + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_fatal_on_timeout(self, mock_pymisp_class): + """Test that FATAL is logged on connection timeout. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = socket.timeout("timed out") + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Timeout MISP") + + create_misp_client(config) + + # Verify FATAL was logged with instance name and timeout info + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "Timeout MISP" in str(call_args) + assert "timeout" in str(call_args).lower() + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_on_dns_failure(self, mock_pymisp_class): + """Test that None is returned on DNS resolution failure. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = socket.gaierror(11001, "getaddrinfo failed") + + config = _create_instance_config(name="DNS Fail MISP") + + result = create_misp_client(config) + + assert result is None + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_fatal_on_dns_failure(self, mock_pymisp_class): + """Test that FATAL is logged on DNS resolution failure. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = socket.gaierror(11001, "getaddrinfo failed") + mock_logs.log.reset_mock() + + config = _create_instance_config(name="DNS Fail MISP") + + create_misp_client(config) + + # Verify FATAL was logged with instance name and DNS error + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "DNS Fail MISP" in str(call_args) + assert "DNS" in str(call_args) or "resolve" in str(call_args).lower() + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_on_connection_refused(self, mock_pymisp_class): + """Test that None is returned when connection is refused. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = ConnectionRefusedError("Connection refused") + + config = _create_instance_config(name="Refused MISP") + + result = create_misp_client(config) + + assert result is None + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_fatal_on_connection_refused(self, mock_pymisp_class): + """Test that FATAL is logged when connection is refused. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = ConnectionRefusedError("Connection refused") + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Refused MISP") + + create_misp_client(config) + + # Verify FATAL was logged + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "Refused MISP" in str(call_args) + assert "refused" in str(call_args).lower() + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_on_generic_connection_error(self, mock_pymisp_class): + """Test that None is returned on generic connection error. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = ConnectionError("Connection failed") + + config = _create_instance_config(name="Error MISP") + + result = create_misp_client(config) + + assert result is None + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_on_unexpected_exception(self, mock_pymisp_class): + """Test that None is returned on unexpected exceptions. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = Exception("Unexpected error") + + config = _create_instance_config(name="Exception MISP") + + result = create_misp_client(config) + + assert result is None + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_fatal_on_unexpected_exception(self, mock_pymisp_class): + """Test that FATAL is logged on unexpected exceptions. + + **Validates: Requirements 9.6** + """ + mock_pymisp_class.side_effect = Exception("Unexpected error message") + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Exception MISP") + + create_misp_client(config) + + # Verify FATAL was logged with instance name and error + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "Exception MISP" in str(call_args) + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_when_proxy_config_incomplete(self, mock_pymisp_class): + """Test behavior when proxy config is incomplete (missing required fields). + + **Validates: Requirements 9.6** + + When proxy is enabled and proxy_config is provided but incomplete (has dict + but missing proxy_host/proxy_port), the function should return None and log FATAL. + """ + mock_logs.log.reset_mock() + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(name="Incomplete Proxy MISP", proxy=True) + # Provide a non-empty proxy_config that's missing required fields + proxy_config = {"some_other_key": "value"} + + result = create_misp_client(config, proxy_config) + + assert result is None + # PyMISP should not be called since proxy config is incomplete + mock_pymisp_class.assert_not_called() + # FATAL should be logged about incomplete proxy config + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "Incomplete Proxy MISP" in str(call_args) + + +class TestConnectionValidation: + """Tests for connection validation after PyMISP initialization. + + **Validates: Requirements 9.1, 9.2** + """ + + @patch('Engines.sharing.connector.PyMISP') + def test_verifies_connection_by_fetching_version(self, mock_pymisp_class): + """Test that connection is verified by fetching MISP version.""" + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config() + + result = create_misp_client(config) + + assert result is mock_client + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_when_version_response_has_errors(self, mock_pymisp_class): + """Test that None is returned when version response contains errors.""" + mock_client = MagicMock() + mock_client.misp_instance_version = {'errors': 'Authentication failed'} + mock_pymisp_class.return_value = mock_client + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Auth Fail MISP") + + result = create_misp_client(config) + + assert result is None + # Verify FATAL was logged + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "Auth Fail MISP" in str(call_args) + + @patch('Engines.sharing.connector.PyMISP') + def test_returns_none_when_version_response_is_none(self, mock_pymisp_class): + """Test that None is returned when version response is None.""" + mock_client = MagicMock() + mock_client.misp_instance_version = None + mock_pymisp_class.return_value = mock_client + mock_logs.log.reset_mock() + + config = _create_instance_config(name="No Response MISP") + + result = create_misp_client(config) + + assert result is None + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_success_on_successful_connection(self, mock_pymisp_class): + """Test that SUCCESS is logged on successful connection.""" + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Success MISP") + + create_misp_client(config) + + # Find the SUCCESS log call + success_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "SUCCESS"] + assert len(success_calls) >= 1 + assert "Success MISP" in str(success_calls[-1]) + + +class TestProxyFailureBehavior: + """Tests for proxy-related failure behavior. + + **Validates: Requirements 9.6** + """ + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_fatal_when_proxy_config_missing_host(self, mock_pymisp_class): + """Test that FATAL is logged when proxy host is missing.""" + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Missing Host MISP", proxy=True) + proxy_config = {"proxy_port": "8080"} # Missing proxy_host + + result = create_misp_client(config, proxy_config) + + assert result is None + # Verify FATAL was logged about incomplete proxy config + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "Missing Host MISP" in str(call_args) + assert "proxy" in str(call_args).lower() + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_fatal_when_proxy_config_missing_port(self, mock_pymisp_class): + """Test that FATAL is logged when proxy port is missing.""" + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Missing Port MISP", proxy=True) + proxy_config = {"proxy_host": "proxy.example.org"} # Missing proxy_port + + result = create_misp_client(config, proxy_config) + + assert result is None + # Verify FATAL was logged about incomplete proxy config + mock_logs.log.assert_called() + call_args = mock_logs.log.call_args_list[-1] + assert call_args[0][0] == "FATAL" + assert "Missing Port MISP" in str(call_args) + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_info_for_authenticated_proxy(self, mock_pymisp_class): + """Test that INFO is logged when using authenticated proxy.""" + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Auth Proxy MISP", proxy=True) + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080", + "proxy_user": "user", + "proxy_password": "pass" + } + + create_misp_client(config, proxy_config) + + # Verify INFO was logged about proxy usage + info_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "INFO"] + assert len(info_calls) >= 1 + assert "Auth Proxy MISP" in str(info_calls[0]) + assert "authentication" in str(info_calls[0]).lower() or "authenticated" in str(info_calls[0]).lower() + + @patch('Engines.sharing.connector.PyMISP') + def test_logs_info_for_unauthenticated_proxy(self, mock_pymisp_class): + """Test that INFO is logged when using unauthenticated proxy.""" + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + mock_logs.log.reset_mock() + + config = _create_instance_config(name="Unauth Proxy MISP", proxy=True) + proxy_config = { + "proxy_host": "proxy.example.org", + "proxy_port": "8080" + } + + create_misp_client(config, proxy_config) + + # Verify INFO was logged about proxy usage + info_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "INFO"] + assert len(info_calls) >= 1 + assert "Unauth Proxy MISP" in str(info_calls[0]) + + +class TestEdgeCases: + """Edge case tests for the connector module. + + **Validates: Requirements 9.1, 9.2, 9.3, 9.4** + """ + + @patch('Engines.sharing.connector.PyMISP') + def test_proxy_enabled_but_no_proxy_config_provided(self, mock_pymisp_class): + """Test behavior when proxy is enabled but no config is provided.""" + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(proxy=True) + # No proxy_config provided (None) + + result = create_misp_client(config, proxy_config=None) + + # Should proceed without proxy since proxy_config is None + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['proxies'] is None + + @patch('Engines.sharing.connector.PyMISP') + def test_http_url_accepted(self, mock_pymisp_class): + """Test that HTTP URLs are accepted (for internal instances).""" + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(url="http://misp.internal.local") + + result = create_misp_client(config) + + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['url'] == "http://misp.internal.local" + assert result is mock_client + + @patch('Engines.sharing.connector.PyMISP') + def test_https_url_accepted(self, mock_pymisp_class): + """Test that HTTPS URLs are accepted.""" + mock_client = MagicMock() + mock_client.misp_instance_version = {'version': '2.4.170'} + mock_pymisp_class.return_value = mock_client + + config = _create_instance_config(url="https://misp.secure.org") + + result = create_misp_client(config) + + call_kwargs = mock_pymisp_class.call_args[1] + assert call_kwargs['url'] == "https://misp.secure.org" + assert result is mock_client + + @patch('Engines.sharing.connector.PyMISP') + def test_version_as_string_handled(self, mock_pymisp_class): + """Test that non-dict version response is handled gracefully.""" + mock_client = MagicMock() + # Version as a string instead of dict + mock_client.misp_instance_version = "2.4.170" + mock_pymisp_class.return_value = mock_client + mock_logs.log.reset_mock() + + config = _create_instance_config(name="String Version MISP") + + result = create_misp_client(config) + + # Should log SUCCESS with 'unknown' version + success_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "SUCCESS"] + assert len(success_calls) >= 1 + assert result is mock_client diff --git a/Engines/sharing/tests/test_events.py b/Engines/sharing/tests/test_events.py new file mode 100644 index 00000000..c4768b92 --- /dev/null +++ b/Engines/sharing/tests/test_events.py @@ -0,0 +1,1454 @@ +"""Tests for Engines/sharing/events.py — MISP event lifecycle tests. + +This module tests the event lifecycle functions in the sharing pipeline: +- build_opentide_misp_object(): MISP object construction +- check_existence(): Existence check querying MISP for existing events +- create_event(): Creating new MISP events +- update_event(): Updating existing MISP events +- should_update_event(): Version comparison logic + +Requirements tested: +- 3.1: Query by org_uuid and opentide uuid attribute +- 3.2: Filter to events owned by org_uuid +- 3.3: Handle multiple matches - use most recently modified +- 3.4: Handle API errors gracefully +- 4.1: Compare versions using integer numeric comparison +- 4.2: Update when local > remote +- 4.3: Skip when local <= remote +- 4.4: Create new event when no matching event found +- 4.5: Handle missing/unparseable version as 0 +- 5.3: Required attributes: name, opentide-object, opentide-type, uuid, version +- 5.4: opentide-relation with UUIDs according to object type rules +- 5.8: Omit opentide-relation if no resolvable relations +- 1.8: Publish without email when publish_on_change is True +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + + +# ============================================================================ +# Mock Setup - must be done BEFORE any imports that trigger DataTide +# ============================================================================ + +class MockHelperTide: + """Mock HelperTide for tests.""" + + @staticmethod + def is_debug(): + return True + + @staticmethod + def fetch_config_envvar(config_secrets: dict) -> dict: + """Mock environment variable resolution.""" + return dict(config_secrets) + + +# Mock the tide module before importing events to avoid DataTide initialization +mock_tide = MagicMock() +mock_tide.HelperTide = MockHelperTide +mock_tide.DataTide = MagicMock() +mock_tide.DataTide.Models = MagicMock() +mock_tide.DataTide.Models.chaining = {} +sys.modules['Engines.modules.tide'] = mock_tide + +# Mock the logs module +mock_logs = MagicMock() +mock_logs.log = MagicMock() +sys.modules['Engines.modules.logs'] = mock_logs + +import pytest +import yaml + +# Remove cached modules if present to ensure fresh import with mocks +modules_to_clear = [ + 'Engines.modules.sharing', + 'Engines.sharing.events', + 'Engines.sharing.relations', + 'Engines.sharing.tagging', +] +for mod in modules_to_clear: + if mod in sys.modules: + del sys.modules[mod] + +from pymisp import MISPObject, MISPEvent, MISPAttribute + +from Engines.modules.sharing import MISPInstanceConfig, TLPLevel +from Engines.sharing.events import ( + build_opentide_misp_object, + OPENTIDE_TEMPLATE_UUID, + ExistenceResult, + should_update_event, + log_skip_version_current, + check_existence, + create_event, + update_event, + _event_has_matching_opentide, + _extract_opentide_version, + API_TIMEOUT, +) + + +def _create_instance_config( + name: str = "Test MISP", + url: str = "https://misp.test.org", + token: str = "test-api-token", + org_uuid: str = "11111111-2222-3333-4444-555555555555", + max_allowed_tlp: TLPLevel = TLPLevel.AMBER, + mode: str = "send", + proxy: bool = False, + publish_on_change: bool = True, + verify_ssl: bool = True +) -> MISPInstanceConfig: + """Helper function to create MISPInstanceConfig for tests.""" + return MISPInstanceConfig( + name=name, + url=url, + token=token, + org_uuid=org_uuid, + max_allowed_tlp=max_allowed_tlp, + mode=mode, + proxy=proxy, + publish_on_change=publish_on_change, + verify_ssl=verify_ssl + ) + + +def _create_mock_misp_event( + event_uuid: str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", + timestamp: int = 1700000000, + opentide_uuid: str = None, + opentide_version: str = "1", + has_opentide_object: bool = True +) -> MISPEvent: + """Helper function to create a mock MISPEvent for tests.""" + event = MagicMock(spec=MISPEvent) + event.uuid = event_uuid + event.timestamp = timestamp + event.Tag = [] + event.Galaxy = [] + + if has_opentide_object and opentide_uuid: + # Create a mock opentide object with attributes + mock_opentide_obj = MagicMock() + mock_opentide_obj.name = "opentide" + + # Create mock attributes + uuid_attr = MagicMock() + uuid_attr.object_relation = "uuid" + uuid_attr.value = opentide_uuid + + version_attr = MagicMock() + version_attr.object_relation = "version" + version_attr.value = opentide_version + + mock_opentide_obj.Attribute = [uuid_attr, version_attr] + event.Object = [mock_opentide_obj] + else: + event.Object = [] + + return event + + +class TestBuildOpentideMispObject: + """Tests for the build_opentide_misp_object() function.""" + + def test_template_uuid_is_correct(self): + """Verify the template UUID constant is set correctly.""" + assert OPENTIDE_TEMPLATE_UUID == "892fd46a-f69e-455c-8c4f-843a4b8f4295" + + def test_creates_misp_object_with_correct_name(self): + """Test that the MISPObject has name='opentide'.""" + object_data = { + "metadata": { + "uuid": "11111111-1111-1111-1111-111111111111", + "version": 1, + }, + "name": "Test Object", + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid="11111111-1111-1111-1111-111111111111", + object_type="tvm", + object_data=object_data, + object_name="Test Object" + ) + + assert isinstance(result, MISPObject) + assert result.name == "opentide" + + def test_required_attributes_present(self): + """Test that all required attributes are present in the MISP object. + + Requirements 5.3: Required attributes: name, opentide-object, + opentide-type, uuid, version + """ + object_uuid = "22222222-2222-2222-2222-222222222222" + object_data = { + "metadata": { + "uuid": object_uuid, + "version": 5, + }, + "name": "My Test TVM", + "description": "A test description", + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="My Test TVM" + ) + + # Extract attribute values by object_relation + attrs = {attr.object_relation: attr.value for attr in result.Attribute} + + assert "name" in attrs + assert "opentide-object" in attrs + assert "opentide-type" in attrs + assert "uuid" in attrs + assert "version" in attrs + + def test_name_attribute_value(self): + """Test that the name attribute contains the object title.""" + object_data = { + "metadata": { + "uuid": "33333333-3333-3333-3333-333333333333", + "version": 1, + }, + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid="33333333-3333-3333-3333-333333333333", + object_type="dom", + object_data=object_data, + object_name="My Detection Objective" + ) + + attrs = {attr.object_relation: attr.value for attr in result.Attribute} + assert attrs["name"] == "My Detection Objective" + + def test_opentide_object_attribute_is_yaml(self): + """Test that opentide-object contains YAML serialization of object_data.""" + object_data = { + "metadata": { + "uuid": "44444444-4444-4444-4444-444444444444", + "version": 3, + }, + "name": "Test", + "nested": {"key": "value"}, + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid="44444444-4444-4444-4444-444444444444", + object_type="mdr", + object_data=object_data, + object_name="Test" + ) + + attrs = {attr.object_relation: attr.value for attr in result.Attribute} + + # Parse the YAML content and verify it matches original data + parsed_yaml = yaml.safe_load(attrs["opentide-object"]) + assert parsed_yaml == object_data + + def test_opentide_type_attribute_values(self): + """Test that opentide-type is set correctly for each object type.""" + object_data = { + "metadata": { + "uuid": "55555555-5555-5555-5555-555555555555", + "version": 1, + }, + } + + for obj_type in ["tvm", "dom", "mdr"]: + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid="55555555-5555-5555-5555-555555555555", + object_type=obj_type, + object_data=object_data, + object_name="Test" + ) + + attrs = {attr.object_relation: attr.value for attr in result.Attribute} + assert attrs["opentide-type"] == obj_type + + def test_uuid_attribute_value(self): + """Test that the uuid attribute contains the OpenTIDE object UUID.""" + object_uuid = "66666666-6666-6666-6666-666666666666" + object_data = { + "metadata": { + "uuid": object_uuid, + "version": 1, + }, + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test" + ) + + attrs = {attr.object_relation: attr.value for attr in result.Attribute} + assert attrs["uuid"] == object_uuid + + def test_version_attribute_is_string(self): + """Test that the version attribute is converted to a string.""" + object_data = { + "metadata": { + "uuid": "77777777-7777-7777-7777-777777777777", + "version": 42, + }, + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid="77777777-7777-7777-7777-777777777777", + object_type="tvm", + object_data=object_data, + object_name="Test" + ) + + attrs = {attr.object_relation: attr.value for attr in result.Attribute} + assert attrs["version"] == "42" + assert isinstance(attrs["version"], str) + + def test_relations_added_as_multi_value(self): + """Test that opentide-relation is added as multi-value attribute. + + Requirements 5.4: opentide-relation with UUIDs according to object type rules + """ + object_uuid = "88888888-8888-8888-8888-888888888888" + object_data = { + "metadata": { + "uuid": object_uuid, + "version": 1, + }, + } + + mock_relations = [ + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "cccccccc-cccc-cccc-cccc-cccccccccccc", + ] + + with patch('Engines.sharing.events.resolve_relations', return_value=mock_relations): + result = build_opentide_misp_object( + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test" + ) + + # Get all opentide-relation attribute values + relation_values = [ + attr.value for attr in result.Attribute + if attr.object_relation == "opentide-relation" + ] + + assert len(relation_values) == 3 + assert set(relation_values) == set(mock_relations) + + def test_relations_omitted_when_empty(self): + """Test that opentide-relation is omitted when no relations exist. + + Requirements 5.8: Omit opentide-relation if no resolvable relations + """ + object_uuid = "99999999-9999-9999-9999-999999999999" + object_data = { + "metadata": { + "uuid": object_uuid, + "version": 1, + }, + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test" + ) + + # Check that no opentide-relation attributes exist + relation_attrs = [ + attr for attr in result.Attribute + if attr.object_relation == "opentide-relation" + ] + + assert len(relation_attrs) == 0 + + def test_default_version_when_missing(self): + """Test that version defaults to '0' when metadata.version is missing.""" + object_data = { + "metadata": { + "uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + # version is missing + }, + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + object_type="tvm", + object_data=object_data, + object_name="Test" + ) + + attrs = {attr.object_relation: attr.value for attr in result.Attribute} + assert attrs["version"] == "0" + + +class TestExistenceResult: + """Tests for the ExistenceResult dataclass.""" + + def test_default_values(self): + """Test that ExistenceResult has correct default values.""" + result = ExistenceResult(found=False) + assert result.found is False + assert result.event is None + assert result.remote_version == 0 + + def test_with_event(self): + """Test ExistenceResult with an event.""" + mock_event = MagicMock() + result = ExistenceResult(found=True, event=mock_event, remote_version=5) + assert result.found is True + assert result.event == mock_event + assert result.remote_version == 5 + + +class TestShouldUpdateEvent: + """Tests for the should_update_event() function.""" + + def test_update_when_local_greater(self): + """Test that update is indicated when local version > remote version.""" + should_update, reason = should_update_event(local_version=5, remote_version=3) + assert should_update is True + assert reason == "update" + + def test_skip_when_versions_equal(self): + """Test that skip is indicated when versions are equal.""" + should_update, reason = should_update_event(local_version=3, remote_version=3) + assert should_update is False + assert reason == "skip" + + def test_skip_when_local_less(self): + """Test that skip is indicated when local version < remote version.""" + should_update, reason = should_update_event(local_version=2, remote_version=5) + assert should_update is False + assert reason == "skip" + + def test_update_when_remote_is_zero(self): + """Test update when remote version is 0 (missing/unparseable).""" + should_update, reason = should_update_event(local_version=1, remote_version=0) + assert should_update is True + assert reason == "update" + + +# ============================================================================ +# Tests for check_existence() function +# ============================================================================ + +class TestCheckExistenceZeroResults: + """Tests for check_existence() when MISP search returns 0 results. + + **Validates: Requirements 3.1, 3.2, 3.4** + """ + + @patch('Engines.sharing.events.PyMISP') + def test_returns_not_found_when_search_returns_empty_list(self, mock_pymisp_class): + """Test that ExistenceResult.found=False when search returns empty list. + + **Validates: Requirements 3.1, 3.2** + """ + mock_client = MagicMock() + mock_client.search.return_value = [] + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + + assert result.found is False + assert result.event is None + assert result.remote_version == 0 + + @patch('Engines.sharing.events.PyMISP') + def test_returns_not_found_when_search_returns_none(self, mock_pymisp_class): + """Test that ExistenceResult.found=False when search returns None. + + **Validates: Requirements 3.4** + """ + mock_client = MagicMock() + mock_client.search.return_value = None + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + + assert result.found is False + assert result.event is None + + @patch('Engines.sharing.events.PyMISP') + def test_search_called_with_correct_parameters(self, mock_pymisp_class): + """Test that PyMISP.search is called with correct parameters. + + **Validates: Requirements 3.1, 3.2** + + Search should filter by: + - org_uuid (events owned by our org) + - object_name='opentide' + - value matching the OpenTIDE UUID + """ + mock_client = MagicMock() + mock_client.search.return_value = [] + + check_existence( + client=mock_client, + org_uuid="org-uuid-12345", + opentide_uuid="opentide-uuid-67890" + ) + + mock_client.search.assert_called_once_with( + controller='events', + org="org-uuid-12345", + object_name='opentide', + value="opentide-uuid-67890", + pythonify=True, + timeout=API_TIMEOUT + ) + + +class TestCheckExistenceOneResult: + """Tests for check_existence() when MISP search returns 1 result. + + **Validates: Requirements 3.1, 3.2, 4.5** + """ + + @patch('Engines.sharing.events.PyMISP') + def test_returns_found_with_event_when_single_match(self, mock_pymisp_class): + """Test that ExistenceResult.found=True with event when single match exists. + + **Validates: Requirements 3.1, 3.2** + """ + mock_client = MagicMock() + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + mock_event = _create_mock_misp_event( + event_uuid="event-uuid-123", + opentide_uuid=opentide_uuid, + opentide_version="5" + ) + mock_client.search.return_value = [mock_event] + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid=opentide_uuid + ) + + assert result.found is True + assert result.event == mock_event + + @patch('Engines.sharing.events.PyMISP') + def test_extracts_version_from_opentide_object(self, mock_pymisp_class): + """Test that version is extracted from the opentide object. + + **Validates: Requirements 4.5** + """ + mock_client = MagicMock() + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + mock_event = _create_mock_misp_event( + opentide_uuid=opentide_uuid, + opentide_version="42" + ) + mock_client.search.return_value = [mock_event] + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid=opentide_uuid + ) + + assert result.remote_version == 42 + + @patch('Engines.sharing.events.PyMISP') + def test_version_defaults_to_zero_when_missing(self, mock_pymisp_class): + """Test that version defaults to 0 when version attribute is missing. + + **Validates: Requirements 4.5** + """ + mock_client = MagicMock() + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + # Create event with opentide object but no version attribute + mock_event = MagicMock(spec=MISPEvent) + mock_event.uuid = "event-uuid-123" + mock_event.timestamp = 1700000000 + + mock_opentide_obj = MagicMock() + mock_opentide_obj.name = "opentide" + + uuid_attr = MagicMock() + uuid_attr.object_relation = "uuid" + uuid_attr.value = opentide_uuid + # No version attribute + mock_opentide_obj.Attribute = [uuid_attr] + mock_event.Object = [mock_opentide_obj] + + mock_client.search.return_value = [mock_event] + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid=opentide_uuid + ) + + assert result.found is True + assert result.remote_version == 0 + + @patch('Engines.sharing.events.PyMISP') + def test_version_defaults_to_zero_when_unparseable(self, mock_pymisp_class): + """Test that version defaults to 0 when version attribute is not a valid integer. + + **Validates: Requirements 4.5** + """ + mock_client = MagicMock() + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + mock_event = _create_mock_misp_event( + opentide_uuid=opentide_uuid, + opentide_version="not-a-number" # Invalid version + ) + mock_client.search.return_value = [mock_event] + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid=opentide_uuid + ) + + assert result.found is True + assert result.remote_version == 0 + + +class TestCheckExistenceMultipleResults: + """Tests for check_existence() when MISP search returns N>1 results. + + **Validates: Requirements 3.3, 3.4** + """ + + @patch('Engines.sharing.events.PyMISP') + def test_uses_most_recently_modified_event(self, mock_pymisp_class): + """Test that the most recently modified event is used when multiple matches exist. + + **Validates: Requirements 3.3** + """ + mock_client = MagicMock() + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + # Create multiple events with different timestamps + older_event = _create_mock_misp_event( + event_uuid="older-event", + timestamp=1600000000, # Older + opentide_uuid=opentide_uuid, + opentide_version="3" + ) + + newer_event = _create_mock_misp_event( + event_uuid="newer-event", + timestamp=1700000000, # Newer + opentide_uuid=opentide_uuid, + opentide_version="5" + ) + + oldest_event = _create_mock_misp_event( + event_uuid="oldest-event", + timestamp=1500000000, # Oldest + opentide_uuid=opentide_uuid, + opentide_version="1" + ) + + # Return events in random order + mock_client.search.return_value = [older_event, newer_event, oldest_event] + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid=opentide_uuid + ) + + assert result.found is True + # Should return the event with highest timestamp (most recent) + assert result.event.uuid == "newer-event" + assert result.remote_version == 5 + + @patch('Engines.sharing.events.PyMISP') + def test_logs_failure_when_duplicates_found(self, mock_pymisp_class): + """Test that FAILURE is logged when duplicate events are detected. + + **Validates: Requirements 3.3** + """ + mock_client = MagicMock() + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + mock_logs.log.reset_mock() + + event1 = _create_mock_misp_event( + event_uuid="event-1", + timestamp=1600000000, + opentide_uuid=opentide_uuid + ) + event2 = _create_mock_misp_event( + event_uuid="event-2", + timestamp=1700000000, + opentide_uuid=opentide_uuid + ) + + mock_client.search.return_value = [event1, event2] + + check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid=opentide_uuid + ) + + # Verify FAILURE was logged about duplicates + failure_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "FAILURE"] + assert len(failure_calls) >= 1 + assert "duplicate" in str(failure_calls[-1]).lower() or "Duplicate" in str(failure_calls[-1]) + + +class TestCheckExistenceApiErrors: + """Tests for check_existence() error handling. + + **Validates: Requirements 3.4** + """ + + @patch('Engines.sharing.events.PyMISP') + def test_returns_not_found_on_exception(self, mock_pymisp_class): + """Test that ExistenceResult.found=False when API raises exception. + + **Validates: Requirements 3.4** + """ + mock_client = MagicMock() + mock_client.search.side_effect = Exception("Network error") + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + + assert result.found is False + assert result.event is None + + @patch('Engines.sharing.events.PyMISP') + def test_logs_failure_on_exception(self, mock_pymisp_class): + """Test that FAILURE is logged when API raises exception. + + **Validates: Requirements 3.4** + """ + mock_client = MagicMock() + mock_client.search.side_effect = Exception("Connection timeout") + mock_logs.log.reset_mock() + + check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid="test-uuid-123" + ) + + # Verify FAILURE was logged + failure_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "FAILURE"] + assert len(failure_calls) >= 1 + assert "test-uuid-123" in str(failure_calls[-1]) + + @patch('Engines.sharing.events.PyMISP') + def test_returns_not_found_on_timeout(self, mock_pymisp_class): + """Test that ExistenceResult.found=False when API times out. + + **Validates: Requirements 3.4** + """ + import socket + mock_client = MagicMock() + mock_client.search.side_effect = socket.timeout("timed out") + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + + assert result.found is False + + @patch('Engines.sharing.events.PyMISP') + def test_returns_not_found_on_auth_error(self, mock_pymisp_class): + """Test that ExistenceResult.found=False on authentication error. + + **Validates: Requirements 3.4** + """ + mock_client = MagicMock() + mock_client.search.side_effect = Exception("Authentication failed") + + result = check_existence( + client=mock_client, + org_uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + + assert result.found is False + + +# ============================================================================ +# Tests for create_event() function +# ============================================================================ + +class TestCreateEvent: + """Tests for create_event() function. + + **Validates: Requirements 4.4, 1.8** + """ + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_calls_add_event_on_client( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that create_event calls client.add_event. + + **Validates: Requirements 4.4** + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.add_event.return_value = mock_result_event + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + config = _create_instance_config(publish_on_change=False) + + result = create_event( + client=mock_client, + instance_config=config, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 1}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + mock_client.add_event.assert_called_once() + assert result is True + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_publish_called_when_publish_on_change_true( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that client.publish is called with alert=False when publish_on_change is True. + + **Validates: Requirements 1.8** + + WHERE boolean publish_on_change is true, THE Sharing_Engine SHALL + publish without email the MISP event on that instance. + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.add_event.return_value = mock_result_event + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + config = _create_instance_config(publish_on_change=True) + + create_event( + client=mock_client, + instance_config=config, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 1}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + mock_client.publish.assert_called_once() + call_args = mock_client.publish.call_args + assert call_args[1]['alert'] is False + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_publish_not_called_when_publish_on_change_false( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that client.publish is NOT called when publish_on_change is False. + + **Validates: Requirements 1.8** + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.add_event.return_value = mock_result_event + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + config = _create_instance_config(publish_on_change=False) + + create_event( + client=mock_client, + instance_config=config, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 1}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + mock_client.publish.assert_not_called() + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_returns_false_on_api_error( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that create_event returns False when add_event fails. + + **Validates: Requirements 4.4** + """ + mock_client = MagicMock() + mock_client.add_event.side_effect = Exception("API Error") + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + config = _create_instance_config() + + result = create_event( + client=mock_client, + instance_config=config, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 1}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + assert result is False + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_logs_success_on_successful_creation( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that SUCCESS is logged when event is created successfully. + + **Validates: Requirements 4.4** + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.add_event.return_value = mock_result_event + mock_logs.log.reset_mock() + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + config = _create_instance_config(name="Test Instance", publish_on_change=False) + + create_event( + client=mock_client, + instance_config=config, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 1}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + success_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "SUCCESS"] + assert len(success_calls) >= 1 + assert "Test Object" in str(success_calls[-1]) + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_logs_failure_on_api_error( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that FAILURE is logged when create_event fails. + + **Validates: Requirements 4.4** + """ + mock_client = MagicMock() + mock_client.add_event.side_effect = Exception("Connection failed") + mock_logs.log.reset_mock() + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + config = _create_instance_config(name="Failed Instance") + + create_event( + client=mock_client, + instance_config=config, + object_uuid="test-uuid", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 1}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + failure_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "FAILURE"] + assert len(failure_calls) >= 1 + + +# ============================================================================ +# Tests for update_event() function +# ============================================================================ + +class TestUpdateEvent: + """Tests for update_event() function. + + **Validates: Requirements 4.2, 4.3, 1.8** + """ + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_calls_update_event_on_client( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that update_event calls client.update_event. + + **Validates: Requirements 4.2** + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.update_event.return_value = mock_result_event + mock_client.delete_object.return_value = None + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + existing_event = _create_mock_misp_event( + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + opentide_version="1" + ) + + config = _create_instance_config(publish_on_change=False) + + result = update_event( + client=mock_client, + instance_config=config, + existing_event=existing_event, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 2}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + mock_client.update_event.assert_called_once() + assert result is True + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_publish_called_when_publish_on_change_true( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that client.publish is called with alert=False when publish_on_change is True. + + **Validates: Requirements 1.8** + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.update_event.return_value = mock_result_event + mock_client.delete_object.return_value = None + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + existing_event = _create_mock_misp_event( + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + opentide_version="1" + ) + + config = _create_instance_config(publish_on_change=True) + + update_event( + client=mock_client, + instance_config=config, + existing_event=existing_event, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 2}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + mock_client.publish.assert_called_once() + call_args = mock_client.publish.call_args + assert call_args[1]['alert'] is False + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_publish_not_called_when_publish_on_change_false( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that client.publish is NOT called when publish_on_change is False. + + **Validates: Requirements 1.8** + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.update_event.return_value = mock_result_event + mock_client.delete_object.return_value = None + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + existing_event = _create_mock_misp_event( + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + opentide_version="1" + ) + + config = _create_instance_config(publish_on_change=False) + + update_event( + client=mock_client, + instance_config=config, + existing_event=existing_event, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 2}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + mock_client.publish.assert_not_called() + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_returns_false_on_api_error( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that update_event returns False when update_event fails. + + **Validates: Requirements 4.2** + """ + mock_client = MagicMock() + mock_client.update_event.side_effect = Exception("API Error") + mock_client.delete_object.return_value = None + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + existing_event = _create_mock_misp_event( + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + + config = _create_instance_config() + + result = update_event( + client=mock_client, + instance_config=config, + existing_event=existing_event, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 2}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + assert result is False + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_logs_success_on_successful_update( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that SUCCESS is logged when event is updated successfully. + + **Validates: Requirements 4.2** + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.update_event.return_value = mock_result_event + mock_client.delete_object.return_value = None + mock_logs.log.reset_mock() + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + existing_event = _create_mock_misp_event( + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + + config = _create_instance_config(name="Test Instance", publish_on_change=False) + + update_event( + client=mock_client, + instance_config=config, + existing_event=existing_event, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 2}}, + object_name="Updated Object", + tlp=TLPLevel.GREEN + ) + + success_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "SUCCESS"] + assert len(success_calls) >= 1 + assert "Updated Object" in str(success_calls[-1]) + + @patch('Engines.sharing.events.build_actor_galaxies') + @patch('Engines.sharing.events.build_attack_tags') + @patch('Engines.sharing.events.build_tlp_tag') + @patch('Engines.sharing.events.build_opentide_misp_object') + def test_attempts_to_delete_old_opentide_objects( + self, mock_build_object, mock_tlp_tag, mock_attack_tags, mock_actor_galaxies + ): + """Test that update_event attempts to delete old opentide objects. + + **Validates: Requirements 4.2** + """ + mock_client = MagicMock() + mock_result_event = MagicMock(spec=MISPEvent) + mock_result_event.uuid = "result-event-uuid" + mock_client.update_event.return_value = mock_result_event + + mock_build_object.return_value = MagicMock(spec=MISPObject) + mock_tlp_tag.return_value = "tlp:green" # Return a valid tag string + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + # Create existing event with an opentide object + existing_event = _create_mock_misp_event( + opentide_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + ) + # Give the opentide object an ID for deletion + existing_event.Object[0].id = "old-object-id" + + config = _create_instance_config(publish_on_change=False) + + update_event( + client=mock_client, + instance_config=config, + existing_event=existing_event, + object_uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + object_type="tvm", + object_data={"metadata": {"uuid": "test", "version": 2}}, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + mock_client.delete_object.assert_called() + + +class TestLogSkipVersionCurrent: + """Tests for log_skip_version_current() function. + + **Validates: Requirements 4.3** + """ + + def test_logs_skip_message(self): + """Test that SKIP message is logged with correct information. + + **Validates: Requirements 4.3** + """ + mock_logs.log.reset_mock() + + log_skip_version_current( + object_name="Test Object", + object_uuid="test-uuid-123", + local_version=5, + remote_version=5, + instance_name="Test MISP" + ) + + skip_calls = [c for c in mock_logs.log.call_args_list if c[0][0] == "SKIP"] + assert len(skip_calls) >= 1 + call_str = str(skip_calls[-1]) + assert "Test Object" in call_str + assert "5" in call_str # Versions should be in the log + + +# ============================================================================ +# Tests for helper functions +# ============================================================================ + +class TestEventHasMatchingOpentide: + """Tests for the _event_has_matching_opentide() helper function.""" + + def test_returns_true_when_matching_opentide_found(self): + """Test returns True when event has matching opentide object.""" + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + event = _create_mock_misp_event( + opentide_uuid=opentide_uuid, + has_opentide_object=True + ) + + result = _event_has_matching_opentide(event, opentide_uuid) + + assert result is True + + def test_returns_false_when_no_opentide_object(self): + """Test returns False when event has no opentide object.""" + event = _create_mock_misp_event(has_opentide_object=False) + + result = _event_has_matching_opentide(event, "some-uuid") + + assert result is False + + def test_returns_false_when_uuid_does_not_match(self): + """Test returns False when opentide uuid doesn't match.""" + event = _create_mock_misp_event( + opentide_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + has_opentide_object=True + ) + + result = _event_has_matching_opentide(event, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + + assert result is False + + def test_returns_false_when_object_list_is_none(self): + """Test returns False when event.Object is None.""" + event = MagicMock(spec=MISPEvent) + event.Object = None + + result = _event_has_matching_opentide(event, "some-uuid") + + assert result is False + + +class TestExtractOpentideVersion: + """Tests for the _extract_opentide_version() helper function.""" + + def test_returns_version_when_found(self): + """Test returns version when opentide object has version attribute.""" + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + event = _create_mock_misp_event( + opentide_uuid=opentide_uuid, + opentide_version="42" + ) + + result = _extract_opentide_version(event, opentide_uuid) + + assert result == 42 + + def test_returns_zero_when_version_missing(self): + """Test returns 0 when version attribute is missing.""" + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + # Create event with opentide object but no version attribute + event = MagicMock(spec=MISPEvent) + mock_opentide_obj = MagicMock() + mock_opentide_obj.name = "opentide" + + uuid_attr = MagicMock() + uuid_attr.object_relation = "uuid" + uuid_attr.value = opentide_uuid + mock_opentide_obj.Attribute = [uuid_attr] # No version attribute + event.Object = [mock_opentide_obj] + + result = _extract_opentide_version(event, opentide_uuid) + + assert result == 0 + + def test_returns_zero_when_version_not_parseable(self): + """Test returns 0 when version is not a valid integer.""" + opentide_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + event = _create_mock_misp_event( + opentide_uuid=opentide_uuid, + opentide_version="invalid" + ) + + result = _extract_opentide_version(event, opentide_uuid) + + assert result == 0 + + def test_returns_zero_when_no_matching_opentide(self): + """Test returns 0 when no matching opentide object found.""" + event = _create_mock_misp_event( + opentide_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + opentide_version="5" + ) + + # Search for a different UUID + result = _extract_opentide_version(event, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + + assert result == 0 + + +class TestApiTimeout: + """Tests for API timeout constant.""" + + def test_api_timeout_is_30_seconds(self): + """Test that API_TIMEOUT is set to 30 seconds per requirements.""" + assert API_TIMEOUT == 30 diff --git a/Engines/sharing/tests/test_events_lifecycle.py b/Engines/sharing/tests/test_events_lifecycle.py new file mode 100644 index 00000000..c6a3f1c5 --- /dev/null +++ b/Engines/sharing/tests/test_events_lifecycle.py @@ -0,0 +1,989 @@ +"""Tests for Engines/sharing/events.py — Event lifecycle (existence check, create, update). + +This module tests the MISP event lifecycle functions: +- check_existence() for querying existing events +- create_event() for creating new MISP events +- update_event() for updating existing MISP events + +All tests use mocked PyMISP to validate API interaction logic without +a live MISP instance. + +Requirements tested: +- 3.1: Query by org_uuid and opentide uuid attribute +- 3.2: Filter to events owned by org_uuid +- 3.3: Handle multiple matches - use most recently modified +- 3.4: Handle API errors gracefully +- 4.1: Version comparison for update decision +- 4.2: Update when local > remote +- 4.3: Skip when local <= remote +- 4.4: Create when no existing event found +- 1.8: Publish without email when publish_on_change is True +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, call +from dataclasses import dataclass + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + + +# ============================================================================ +# Mock Setup - must be done BEFORE any imports that trigger DataTide +# ============================================================================ + +class MockHelperTide: + """Mock HelperTide for tests.""" + + @staticmethod + def is_debug(): + return True + + @staticmethod + def fetch_config_envvar(config_secrets: dict) -> dict: + """Mock environment variable resolution.""" + return dict(config_secrets) + + +# Mock the tide module before importing events to avoid DataTide initialization +mock_tide = MagicMock() +mock_tide.HelperTide = MockHelperTide +mock_tide.DataTide = MagicMock() +mock_tide.DataTide.Models = MagicMock() +mock_tide.DataTide.Models.chaining = {} +sys.modules['Engines.modules.tide'] = mock_tide + +# Mock the logs module +mock_logs = MagicMock() +mock_logs.log = MagicMock() +sys.modules['Engines.modules.logs'] = mock_logs + +import pytest + +# Remove cached modules if present to ensure fresh import with mocks +modules_to_clear = [ + 'Engines.modules.sharing', + 'Engines.sharing.events', + 'Engines.sharing.relations', + 'Engines.sharing.tagging', +] +for mod in modules_to_clear: + if mod in sys.modules: + del sys.modules[mod] + +from pymisp import MISPEvent, MISPObject, MISPAttribute + +# Now import the module under test +from Engines.sharing.events import ( + check_existence, + create_event, + update_event, + ExistenceResult, + _event_has_matching_opentide, + _extract_opentide_version, +) +from Engines.modules.sharing import MISPInstanceConfig, TLPLevel + + +# ============================================================================ +# Test Fixtures +# ============================================================================ + +@pytest.fixture +def mock_pymisp(): + """Create a mocked PyMISP client.""" + client = MagicMock() + return client + + +@pytest.fixture +def sample_instance_config(): + """Create a sample MISP instance configuration.""" + return MISPInstanceConfig( + name="Test MISP", + url="https://misp.test.org", + token="test-token", + org_uuid="00000000-0000-0000-0000-000000000001", + max_allowed_tlp=TLPLevel.AMBER, + mode="send", + proxy=False, + publish_on_change=True, + verify_ssl=True + ) + + +@pytest.fixture +def sample_instance_config_no_publish(): + """Create a sample MISP instance config with publish_on_change=False.""" + return MISPInstanceConfig( + name="Test MISP", + url="https://misp.test.org", + token="test-token", + org_uuid="00000000-0000-0000-0000-000000000001", + max_allowed_tlp=TLPLevel.AMBER, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True + ) + + +def create_mock_misp_event( + uuid: str, + opentide_uuid: str, + opentide_version: str = "1", + timestamp: int = 1000 +) -> MISPEvent: + """Create a mock MISPEvent with an opentide object.""" + event = MISPEvent() + event.uuid = uuid + event.timestamp = timestamp + event.info = "Test Event" + + # Create a mock opentide object with attributes + opentide_obj = MISPObject(name="opentide") + + uuid_attr = MISPAttribute() + uuid_attr.object_relation = "uuid" + uuid_attr.value = opentide_uuid + + version_attr = MISPAttribute() + version_attr.object_relation = "version" + version_attr.value = opentide_version + + opentide_obj.Attribute = [uuid_attr, version_attr] + event.Object = [opentide_obj] + + return event + + +# ============================================================================ +# Tests for check_existence() - 0, 1, N event results +# ============================================================================ + +class TestCheckExistence: + """Tests for the check_existence() function.""" + + def test_no_matching_events_returns_not_found(self, mock_pymisp): + """Test when search returns no matching events. + + Requirements 3.1, 3.2: Query by org_uuid and opentide uuid attribute. + """ + # Search returns empty list + mock_pymisp.search.return_value = [] + + result = check_existence( + client=mock_pymisp, + org_uuid="00000000-0000-0000-0000-000000000001", + opentide_uuid="11111111-1111-1111-1111-111111111111" + ) + + assert result.found is False + assert result.event is None + assert result.remote_version == 0 + + # Verify search was called with correct parameters + mock_pymisp.search.assert_called_once() + call_kwargs = mock_pymisp.search.call_args.kwargs + assert call_kwargs['controller'] == 'events' + assert call_kwargs['org'] == "00000000-0000-0000-0000-000000000001" + assert call_kwargs['object_name'] == 'opentide' + assert call_kwargs['value'] == "11111111-1111-1111-1111-111111111111" + + def test_one_matching_event_returns_found(self, mock_pymisp): + """Test when search returns exactly one matching event. + + Requirements 3.1, 3.2: Returns the found event with remote version. + """ + opentide_uuid = "22222222-2222-2222-2222-222222222222" + mock_event = create_mock_misp_event( + uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + opentide_uuid=opentide_uuid, + opentide_version="5", + timestamp=1234567890 + ) + mock_pymisp.search.return_value = [mock_event] + + result = check_existence( + client=mock_pymisp, + org_uuid="00000000-0000-0000-0000-000000000001", + opentide_uuid=opentide_uuid + ) + + assert result.found is True + assert result.event is mock_event + assert result.remote_version == 5 + + def test_multiple_matching_events_uses_most_recent(self, mock_pymisp): + """Test when search returns multiple matching events. + + Requirements 3.3: Use most recently modified event. + """ + opentide_uuid = "33333333-3333-3333-3333-333333333333" + + # Create events with different timestamps + old_event = create_mock_misp_event( + uuid="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + opentide_uuid=opentide_uuid, + opentide_version="1", + timestamp=1000 # Older + ) + + newer_event = create_mock_misp_event( + uuid="cccccccc-cccc-cccc-cccc-cccccccccccc", + opentide_uuid=opentide_uuid, + opentide_version="3", + timestamp=3000 # Newer + ) + + middle_event = create_mock_misp_event( + uuid="dddddddd-dddd-dddd-dddd-dddddddddddd", + opentide_uuid=opentide_uuid, + opentide_version="2", + timestamp=2000 # In between + ) + + # Return events in non-chronological order + mock_pymisp.search.return_value = [old_event, middle_event, newer_event] + + with patch('Engines.sharing.events.log') as mock_log: + result = check_existence( + client=mock_pymisp, + org_uuid="00000000-0000-0000-0000-000000000001", + opentide_uuid=opentide_uuid + ) + + # Should return the most recent event + assert result.found is True + assert result.event is newer_event + assert result.remote_version == 3 + + # Should log FAILURE about duplicates + mock_log.assert_called() + log_calls = [str(c) for c in mock_log.call_args_list] + assert any('FAILURE' in str(c) and 'Duplicate' in str(c) for c in log_calls) + + def test_api_error_returns_not_found(self, mock_pymisp): + """Test when API call raises an exception. + + Requirements 3.4: Handle API errors gracefully. + """ + mock_pymisp.search.side_effect = Exception("Network timeout") + + with patch('Engines.sharing.events.log') as mock_log: + result = check_existence( + client=mock_pymisp, + org_uuid="00000000-0000-0000-0000-000000000001", + opentide_uuid="44444444-4444-4444-4444-444444444444" + ) + + assert result.found is False + assert result.event is None + assert result.remote_version == 0 + + # Should log FAILURE + mock_log.assert_called() + assert mock_log.call_args[0][0] == "FAILURE" + + def test_missing_version_attribute_treated_as_zero(self, mock_pymisp): + """Test when opentide object has no version attribute. + + Requirements 4.5: Missing version treated as 0. + """ + opentide_uuid = "55555555-5555-5555-5555-555555555555" + + # Create event with opentide object but no version attribute + event = MISPEvent() + event.uuid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + event.timestamp = 1234567890 + + opentide_obj = MISPObject(name="opentide") + uuid_attr = MISPAttribute() + uuid_attr.object_relation = "uuid" + uuid_attr.value = opentide_uuid + # No version attribute + opentide_obj.Attribute = [uuid_attr] + event.Object = [opentide_obj] + + mock_pymisp.search.return_value = [event] + + result = check_existence( + client=mock_pymisp, + org_uuid="00000000-0000-0000-0000-000000000001", + opentide_uuid=opentide_uuid + ) + + assert result.found is True + assert result.remote_version == 0 + + def test_unparseable_version_treated_as_zero(self, mock_pymisp): + """Test when version attribute cannot be parsed as integer. + + Requirements 4.5: Unparseable version treated as 0. + """ + opentide_uuid = "66666666-6666-6666-6666-666666666666" + mock_event = create_mock_misp_event( + uuid="ffffffff-ffff-ffff-ffff-ffffffffffff", + opentide_uuid=opentide_uuid, + opentide_version="invalid_version", # Not a number + timestamp=1234567890 + ) + mock_pymisp.search.return_value = [mock_event] + + result = check_existence( + client=mock_pymisp, + org_uuid="00000000-0000-0000-0000-000000000001", + opentide_uuid=opentide_uuid + ) + + assert result.found is True + assert result.remote_version == 0 + + def test_events_without_matching_opentide_are_filtered(self, mock_pymisp): + """Test that events without the matching opentide UUID are excluded.""" + target_uuid = "77777777-7777-7777-7777-777777777777" + + # Event with different opentide uuid + non_matching_event = create_mock_misp_event( + uuid="11111111-1111-1111-1111-111111111111", + opentide_uuid="99999999-9999-9999-9999-999999999999", # Different! + opentide_version="5" + ) + + mock_pymisp.search.return_value = [non_matching_event] + + result = check_existence( + client=mock_pymisp, + org_uuid="00000000-0000-0000-0000-000000000001", + opentide_uuid=target_uuid + ) + + # Should not find because the UUID doesn't match + assert result.found is False + + def test_none_search_result_returns_not_found(self, mock_pymisp): + """Test when search returns None.""" + mock_pymisp.search.return_value = None + + result = check_existence( + client=mock_pymisp, + org_uuid="00000000-0000-0000-0000-000000000001", + opentide_uuid="88888888-8888-8888-8888-888888888888" + ) + + assert result.found is False + assert result.event is None + + +# ============================================================================ +# Tests for create_event() +# ============================================================================ + +class TestCreateEvent: + """Tests for the create_event() function.""" + + def test_successful_event_creation(self, mock_pymisp, sample_instance_config): + """Test successful creation of a new MISP event. + + Requirements 4.4: Create new event when no existing event found. + """ + object_uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + object_data = { + "metadata": {"uuid": object_uuid, "version": 1}, + "name": "Test TVM", + "description": "A test threat" + } + + # Mock successful add_event + mock_result = MISPEvent() + mock_result.uuid = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + mock_pymisp.add_event.return_value = mock_result + mock_pymisp.publish.return_value = {"saved": True} + + with patch('Engines.sharing.events.log') as mock_log, \ + patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag') as mock_tlp, \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]): + + mock_tlp.return_value = MagicMock() + + result = create_event( + client=mock_pymisp, + instance_config=sample_instance_config, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is True + mock_pymisp.add_event.assert_called_once() + + # Check SUCCESS was logged + success_calls = [c for c in mock_log.call_args_list if c[0][0] == "SUCCESS"] + assert len(success_calls) == 1 + + def test_publish_on_change_true_calls_publish(self, mock_pymisp, sample_instance_config): + """Test that publish is called when publish_on_change is True. + + Requirements 1.8: Publish without email when publish_on_change is True. + """ + object_uuid = "cccccccc-cccc-cccc-cccc-cccccccccccc" + object_data = { + "metadata": {"uuid": object_uuid, "version": 1}, + "name": "Test TVM" + } + + mock_result = MISPEvent() + mock_result.uuid = "dddddddd-dddd-dddd-dddd-dddddddddddd" + mock_pymisp.add_event.return_value = mock_result + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log'): + + result = create_event( + client=mock_pymisp, + instance_config=sample_instance_config, # publish_on_change=True + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is True + # Verify publish was called with alert=False + mock_pymisp.publish.assert_called_once() + call_kwargs = mock_pymisp.publish.call_args + assert call_kwargs.kwargs.get('alert') is False or \ + (len(call_kwargs.args) > 1 and call_kwargs.args[1] is False) or \ + call_kwargs[1].get('alert') is False + + def test_publish_on_change_false_skips_publish( + self, mock_pymisp, sample_instance_config_no_publish + ): + """Test that publish is NOT called when publish_on_change is False.""" + object_uuid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + object_data = { + "metadata": {"uuid": object_uuid, "version": 1}, + "name": "Test TVM" + } + + mock_result = MISPEvent() + mock_result.uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff" + mock_pymisp.add_event.return_value = mock_result + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log'): + + result = create_event( + client=mock_pymisp, + instance_config=sample_instance_config_no_publish, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is True + # Verify publish was NOT called + mock_pymisp.publish.assert_not_called() + + def test_create_event_api_failure_returns_false( + self, mock_pymisp, sample_instance_config + ): + """Test that API failure during creation returns False. + + Requirements 8.5: Log FAILURE on PyMISP exceptions. + """ + object_uuid = "11111111-2222-3333-4444-555555555555" + object_data = { + "metadata": {"uuid": object_uuid, "version": 1}, + "name": "Test TVM" + } + + # Return error response (not a MISPEvent with uuid) + mock_pymisp.add_event.return_value = {"errors": ["Server error"]} + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log') as mock_log: + + result = create_event( + client=mock_pymisp, + instance_config=sample_instance_config, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is False + # Check FAILURE was logged + failure_calls = [c for c in mock_log.call_args_list if c[0][0] == "FAILURE"] + assert len(failure_calls) >= 1 + + def test_create_event_exception_returns_false( + self, mock_pymisp, sample_instance_config + ): + """Test that exception during creation returns False.""" + object_uuid = "66666666-7777-8888-9999-aaaaaaaaaaaa" + object_data = { + "metadata": {"uuid": object_uuid, "version": 1}, + "name": "Test TVM" + } + + mock_pymisp.add_event.side_effect = Exception("Connection refused") + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log') as mock_log: + + result = create_event( + client=mock_pymisp, + instance_config=sample_instance_config, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is False + # Check FAILURE was logged with exception info + failure_calls = [c for c in mock_log.call_args_list if c[0][0] == "FAILURE"] + assert len(failure_calls) >= 1 + + def test_create_event_uses_deterministic_uuid( + self, mock_pymisp, sample_instance_config + ): + """Test that event UUID is derived deterministically from object UUID. + + Requirements 5.9: Derive event UUID deterministically. + """ + object_uuid = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" + object_data = { + "metadata": {"uuid": object_uuid, "version": 1}, + "name": "Test TVM" + } + + mock_result = MISPEvent() + mock_result.uuid = "result-uuid" + mock_pymisp.add_event.return_value = mock_result + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log'), \ + patch('Engines.sharing.events.derive_event_uuid') as mock_derive: + + mock_derive.return_value = "derived-uuid-12345" + + create_event( + client=mock_pymisp, + instance_config=sample_instance_config, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + # Verify derive_event_uuid was called with the object UUID + mock_derive.assert_called_once_with(object_uuid) + + +# ============================================================================ +# Tests for update_event() +# ============================================================================ + +class TestUpdateEvent: + """Tests for the update_event() function.""" + + def test_successful_event_update(self, mock_pymisp, sample_instance_config): + """Test successful update of an existing MISP event. + + Requirements 4.2: Update when local > remote. + """ + object_uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + object_data = { + "metadata": {"uuid": object_uuid, "version": 5}, + "name": "Updated TVM", + "description": "Updated description" + } + + existing_event = create_mock_misp_event( + uuid="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + opentide_uuid=object_uuid, + opentide_version="1" + ) + + mock_result = MISPEvent() + mock_result.uuid = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + mock_pymisp.update_event.return_value = mock_result + mock_pymisp.delete_object.return_value = {"saved": True} + mock_pymisp.publish.return_value = {"saved": True} + + with patch('Engines.sharing.events.log') as mock_log, \ + patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag') as mock_tlp, \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]): + + mock_tlp.return_value = MagicMock() + + result = update_event( + client=mock_pymisp, + instance_config=sample_instance_config, + existing_event=existing_event, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Updated TVM", + tlp=TLPLevel.AMBER + ) + + assert result is True + mock_pymisp.update_event.assert_called_once() + + # Check SUCCESS was logged + success_calls = [c for c in mock_log.call_args_list if c[0][0] == "SUCCESS"] + assert len(success_calls) == 1 + + def test_update_event_publish_on_change_true( + self, mock_pymisp, sample_instance_config + ): + """Test that publish is called after update when publish_on_change is True. + + Requirements 1.8: Publish without email when publish_on_change is True. + """ + object_uuid = "cccccccc-cccc-cccc-cccc-cccccccccccc" + object_data = { + "metadata": {"uuid": object_uuid, "version": 2}, + "name": "Test TVM" + } + + existing_event = create_mock_misp_event( + uuid="dddddddd-dddd-dddd-dddd-dddddddddddd", + opentide_uuid=object_uuid, + opentide_version="1" + ) + + mock_result = MISPEvent() + mock_result.uuid = "dddddddd-dddd-dddd-dddd-dddddddddddd" + mock_pymisp.update_event.return_value = mock_result + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log'): + + result = update_event( + client=mock_pymisp, + instance_config=sample_instance_config, # publish_on_change=True + existing_event=existing_event, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is True + # Verify publish was called with alert=False + mock_pymisp.publish.assert_called_once() + call_kwargs = mock_pymisp.publish.call_args + assert call_kwargs.kwargs.get('alert') is False or \ + (len(call_kwargs.args) > 1 and call_kwargs.args[1] is False) or \ + call_kwargs[1].get('alert') is False + + def test_update_event_publish_on_change_false( + self, mock_pymisp, sample_instance_config_no_publish + ): + """Test that publish is NOT called after update when publish_on_change is False.""" + object_uuid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + object_data = { + "metadata": {"uuid": object_uuid, "version": 3}, + "name": "Test TVM" + } + + existing_event = create_mock_misp_event( + uuid="ffffffff-ffff-ffff-ffff-ffffffffffff", + opentide_uuid=object_uuid, + opentide_version="2" + ) + + mock_result = MISPEvent() + mock_result.uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff" + mock_pymisp.update_event.return_value = mock_result + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log'): + + result = update_event( + client=mock_pymisp, + instance_config=sample_instance_config_no_publish, + existing_event=existing_event, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is True + mock_pymisp.publish.assert_not_called() + + def test_update_event_api_failure_returns_false( + self, mock_pymisp, sample_instance_config + ): + """Test that API failure during update returns False.""" + object_uuid = "11111111-2222-3333-4444-555555555555" + object_data = { + "metadata": {"uuid": object_uuid, "version": 2}, + "name": "Test TVM" + } + + existing_event = create_mock_misp_event( + uuid="66666666-7777-8888-9999-aaaaaaaaaaaa", + opentide_uuid=object_uuid, + opentide_version="1" + ) + + # Return error response + mock_pymisp.update_event.return_value = {"errors": ["Update failed"]} + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log') as mock_log: + + result = update_event( + client=mock_pymisp, + instance_config=sample_instance_config, + existing_event=existing_event, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is False + failure_calls = [c for c in mock_log.call_args_list if c[0][0] == "FAILURE"] + assert len(failure_calls) >= 1 + + def test_update_event_exception_returns_false( + self, mock_pymisp, sample_instance_config + ): + """Test that exception during update returns False.""" + object_uuid = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" + object_data = { + "metadata": {"uuid": object_uuid, "version": 2}, + "name": "Test TVM" + } + + existing_event = create_mock_misp_event( + uuid="11111111-2222-3333-4444-555555555555", + opentide_uuid=object_uuid, + opentide_version="1" + ) + + mock_pymisp.update_event.side_effect = Exception("Network error") + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log') as mock_log: + + result = update_event( + client=mock_pymisp, + instance_config=sample_instance_config, + existing_event=existing_event, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is False + failure_calls = [c for c in mock_log.call_args_list if c[0][0] == "FAILURE"] + assert len(failure_calls) >= 1 + + def test_update_event_deletes_old_opentide_objects( + self, mock_pymisp, sample_instance_config + ): + """Test that old opentide objects are deleted before adding new one.""" + object_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + object_data = { + "metadata": {"uuid": object_uuid, "version": 2}, + "name": "Test TVM" + } + + # Create existing event with an opentide object that has an ID + existing_event = create_mock_misp_event( + uuid="ffffffff-1111-2222-3333-444444444444", + opentide_uuid=object_uuid, + opentide_version="1" + ) + # Set object ID so it can be deleted + existing_event.Object[0].id = "old-object-id-123" + + mock_result = MISPEvent() + mock_result.uuid = "ffffffff-1111-2222-3333-444444444444" + mock_pymisp.update_event.return_value = mock_result + + with patch('Engines.sharing.events.resolve_relations', return_value=[]), \ + patch('Engines.sharing.events.build_tlp_tag'), \ + patch('Engines.sharing.events.build_attack_tags', return_value=[]), \ + patch('Engines.sharing.events.build_actor_galaxies', return_value=[]), \ + patch('Engines.sharing.events.log'): + + result = update_event( + client=mock_pymisp, + instance_config=sample_instance_config, + existing_event=existing_event, + object_uuid=object_uuid, + object_type="tvm", + object_data=object_data, + object_name="Test TVM", + tlp=TLPLevel.GREEN + ) + + assert result is True + # Verify delete_object was called for the old object + mock_pymisp.delete_object.assert_called_once_with("old-object-id-123") + + +# ============================================================================ +# Tests for helper functions +# ============================================================================ + +class TestEventHasMatchingOpentide: + """Tests for the _event_has_matching_opentide() helper function.""" + + def test_returns_true_for_matching_uuid(self): + """Test returns True when opentide object has matching UUID.""" + opentide_uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + event = create_mock_misp_event( + uuid="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + opentide_uuid=opentide_uuid, + opentide_version="1" + ) + + result = _event_has_matching_opentide(event, opentide_uuid) + assert result is True + + def test_returns_false_for_non_matching_uuid(self): + """Test returns False when opentide object has different UUID.""" + event = create_mock_misp_event( + uuid="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + opentide_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + opentide_version="1" + ) + + result = _event_has_matching_opentide(event, "different-uuid") + assert result is False + + def test_returns_false_for_event_without_objects(self): + """Test returns False when event has no objects.""" + event = MISPEvent() + event.uuid = "cccccccc-cccc-cccc-cccc-cccccccccccc" + event.Object = [] + + result = _event_has_matching_opentide(event, "any-uuid") + assert result is False + + def test_returns_false_for_non_opentide_objects(self): + """Test returns False when event only has non-opentide objects.""" + event = MISPEvent() + event.uuid = "dddddddd-dddd-dddd-dddd-dddddddddddd" + + other_obj = MISPObject(name="file") + other_obj.Attribute = [] + event.Object = [other_obj] + + result = _event_has_matching_opentide(event, "any-uuid") + assert result is False + + +class TestExtractOpentideVersion: + """Tests for the _extract_opentide_version() helper function.""" + + def test_extracts_numeric_version(self): + """Test extraction of numeric version from opentide object.""" + opentide_uuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + event = create_mock_misp_event( + uuid="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + opentide_uuid=opentide_uuid, + opentide_version="42" + ) + + result = _extract_opentide_version(event, opentide_uuid) + assert result == 42 + + def test_returns_zero_for_missing_version(self): + """Test returns 0 when version attribute is missing.""" + opentide_uuid = "cccccccc-cccc-cccc-cccc-cccccccccccc" + + event = MISPEvent() + event.uuid = "dddddddd-dddd-dddd-dddd-dddddddddddd" + + opentide_obj = MISPObject(name="opentide") + uuid_attr = MISPAttribute() + uuid_attr.object_relation = "uuid" + uuid_attr.value = opentide_uuid + # No version attribute + opentide_obj.Attribute = [uuid_attr] + event.Object = [opentide_obj] + + result = _extract_opentide_version(event, opentide_uuid) + assert result == 0 + + def test_returns_zero_for_unparseable_version(self): + """Test returns 0 when version cannot be parsed as integer.""" + opentide_uuid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + event = create_mock_misp_event( + uuid="ffffffff-ffff-ffff-ffff-ffffffffffff", + opentide_uuid=opentide_uuid, + opentide_version="not-a-number" + ) + + result = _extract_opentide_version(event, opentide_uuid) + assert result == 0 + + def test_returns_zero_for_no_matching_object(self): + """Test returns 0 when no matching opentide object exists.""" + event = MISPEvent() + event.uuid = "11111111-1111-1111-1111-111111111111" + event.Object = [] + + result = _extract_opentide_version(event, "any-uuid") + assert result == 0 + diff --git a/Engines/sharing/tests/test_misp_object_completeness_property.py b/Engines/sharing/tests/test_misp_object_completeness_property.py new file mode 100644 index 00000000..7ad62879 --- /dev/null +++ b/Engines/sharing/tests/test_misp_object_completeness_property.py @@ -0,0 +1,673 @@ +"""Property tests for MISP object attribute completeness (Property 7). + +**Validates: Requirements 5.3** + +Property 7: MISP object attribute completeness +*For any* OpenTIDE object (TVM, DOM, or MDR) with valid metadata (uuid, version, name), +the constructed `opentide` MISP object SHALL contain all required attributes: +`name`, `opentide-object` (YAML serialization), `opentide-type`, `uuid`, and `version`. + +This module uses Hypothesis property-based testing to verify that the +build_opentide_misp_object() function always produces MISP objects with +all required attributes, regardless of the input object data. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + + +# ============================================================================ +# Mock Setup - must be done BEFORE any imports that trigger DataTide +# ============================================================================ + +class MockHelperTide: + """Mock HelperTide for tests.""" + + @staticmethod + def is_debug(): + return True + + @staticmethod + def fetch_config_envvar(config_secrets: dict) -> dict: + """Mock environment variable resolution.""" + return dict(config_secrets) + + +# Mock the tide module before importing events to avoid DataTide initialization +mock_tide = MagicMock() +mock_tide.HelperTide = MockHelperTide +mock_tide.DataTide = MagicMock() +mock_tide.DataTide.Models = MagicMock() +mock_tide.DataTide.Models.chaining = {} +sys.modules['Engines.modules.tide'] = mock_tide + +# Mock the logs module +mock_logs = MagicMock() +mock_logs.log = MagicMock() +sys.modules['Engines.modules.logs'] = mock_logs + +import uuid + +import pytest +import yaml +from hypothesis import given, strategies as st, assume, settings, HealthCheck +from pymisp import MISPObject + +# Remove cached modules if present to ensure fresh import with mocks +modules_to_clear = [ + 'Engines.modules.sharing', + 'Engines.sharing.events', + 'Engines.sharing.relations', + 'Engines.sharing.tagging', +] +for mod in modules_to_clear: + if mod in sys.modules: + del sys.modules[mod] + +from Engines.sharing.events import ( + build_opentide_misp_object, + OPENTIDE_TEMPLATE_UUID, +) + + +# ============================================================================= +# Strategies for property-based tests +# ============================================================================= + +# Strategy for generating valid UUIDs +uuid_strategy = st.uuids().map(str) + + +# Strategy for valid version numbers (positive integers) +version_strategy = st.integers(min_value=0, max_value=1_000_000) + + +# Strategy for object names (non-empty strings, stripped) +# Note: PyMISP strips whitespace from attribute values, so we generate +# names that are already stripped to match the expected behavior. +name_strategy = st.text( + alphabet=st.characters( + whitelist_categories=('L', 'N', 'P', 'Zs'), + blacklist_categories=('Cc', 'Cs'), + ), + min_size=1, + max_size=256 +).map(lambda x: x.strip()).filter(lambda x: len(x) > 0) # Strip and ensure non-empty + + +# Strategy for object types +object_type_strategy = st.sampled_from(["tvm", "dom", "mdr"]) + + +@st.composite +def metadata_strategy(draw): + """Generate valid metadata dictionaries with uuid and version.""" + return { + "uuid": draw(uuid_strategy), + "version": draw(version_strategy), + } + + +@st.composite +def simple_object_data_strategy(draw): + """Generate simple valid OpenTIDE object dictionaries. + + These contain the minimum required fields: metadata with uuid and version. + """ + metadata = draw(metadata_strategy()) + name = draw(name_strategy) + + return { + "metadata": metadata, + "name": name, + } + + +@st.composite +def nested_dict_strategy(draw, max_depth=2): + """Generate nested dictionary structures for testing YAML serialization. + + Creates dictionaries that may contain nested structures to verify + that YAML serialization works correctly for complex object data. + """ + if max_depth <= 0: + # Base case: return simple values + return draw(st.one_of( + st.text(min_size=0, max_size=50), + st.integers(), + st.floats(allow_nan=False, allow_infinity=False), + st.booleans(), + st.none(), + )) + + # Recursive case: potentially nested structures + return draw(st.one_of( + st.text(min_size=0, max_size=50), + st.integers(), + st.floats(allow_nan=False, allow_infinity=False), + st.booleans(), + st.none(), + st.lists( + nested_dict_strategy(max_depth=max_depth - 1), + max_size=5 + ), + st.dictionaries( + keys=st.text(min_size=1, max_size=20).filter(lambda x: x.strip()), + values=nested_dict_strategy(max_depth=max_depth - 1), + max_size=5 + ), + )) + + +@st.composite +def complex_object_data_strategy(draw): + """Generate complex OpenTIDE object dictionaries with nested data. + + These contain the required metadata fields plus additional nested + structures to verify YAML serialization handles complex data. + """ + metadata = draw(metadata_strategy()) + name = draw(name_strategy) + + # Base object with required fields + obj = { + "metadata": metadata, + "name": name, + } + + # Add some optional nested fields + num_extra_fields = draw(st.integers(min_value=0, max_value=5)) + for _ in range(num_extra_fields): + field_name = draw(st.text(min_size=1, max_size=20).filter( + lambda x: x.strip() and x not in obj + )) + if field_name and field_name not in obj: + obj[field_name] = draw(nested_dict_strategy(max_depth=2)) + + return obj + + +# Combined strategy for any valid object data +object_data_strategy = st.one_of( + simple_object_data_strategy(), + complex_object_data_strategy(), +) + + +@st.composite +def opentide_object_inputs_strategy(draw): + """Generate complete valid inputs for build_opentide_misp_object(). + + Returns a dictionary with: + - object_uuid: The UUID from the object's metadata + - object_type: One of "tvm", "dom", "mdr" + - object_data: The full object dictionary + - object_name: The object's display name + """ + object_data = draw(object_data_strategy) + object_type = draw(object_type_strategy) + object_name = draw(name_strategy) + + # Use the UUID from metadata + object_uuid = object_data["metadata"]["uuid"] + + return { + "object_uuid": object_uuid, + "object_type": object_type, + "object_data": object_data, + "object_name": object_name, + } + + +# ============================================================================= +# Property Tests +# ============================================================================= + +class TestMISPObjectAttributeCompleteness: + """Property tests for MISP object attribute completeness. + + **Validates: Requirements 5.3** + + These tests verify that for any valid OpenTIDE object, the constructed + MISP object always contains all required attributes. + """ + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_all_required_attributes_present(self, inputs: dict): + """Test that all required attributes are always present. + + **Validates: Requirements 5.3** + + For any valid OpenTIDE object, the constructed MISP object SHALL contain: + - name + - opentide-object (YAML serialization) + - opentide-type + - uuid + - version + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Get attribute names from the MISP object + attr_relations = {attr.object_relation for attr in result.Attribute} + + required_attributes = {"name", "opentide-object", "opentide-type", "uuid", "version"} + + assert required_attributes.issubset(attr_relations), ( + f"Missing required attributes. " + f"Expected: {required_attributes}, " + f"Got: {attr_relations}, " + f"Missing: {required_attributes - attr_relations}" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_name_attribute_matches_input(self, inputs: dict): + """Test that the name attribute matches the provided object_name. + + **Validates: Requirements 5.3** + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Extract the name attribute value + name_attrs = [attr for attr in result.Attribute if attr.object_relation == "name"] + + assert len(name_attrs) == 1, f"Expected exactly one 'name' attribute, got {len(name_attrs)}" + assert name_attrs[0].value == inputs["object_name"], ( + f"Name mismatch: expected '{inputs['object_name']}', " + f"got '{name_attrs[0].value}'" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_uuid_attribute_matches_input(self, inputs: dict): + """Test that the uuid attribute matches the provided object_uuid. + + **Validates: Requirements 5.3** + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Extract the uuid attribute value + uuid_attrs = [attr for attr in result.Attribute if attr.object_relation == "uuid"] + + assert len(uuid_attrs) == 1, f"Expected exactly one 'uuid' attribute, got {len(uuid_attrs)}" + assert uuid_attrs[0].value == inputs["object_uuid"], ( + f"UUID mismatch: expected '{inputs['object_uuid']}', " + f"got '{uuid_attrs[0].value}'" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_opentide_type_matches_input(self, inputs: dict): + """Test that the opentide-type attribute matches the provided object_type. + + **Validates: Requirements 5.3** + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Extract the opentide-type attribute value + type_attrs = [attr for attr in result.Attribute if attr.object_relation == "opentide-type"] + + assert len(type_attrs) == 1, f"Expected exactly one 'opentide-type' attribute, got {len(type_attrs)}" + assert type_attrs[0].value == inputs["object_type"], ( + f"Type mismatch: expected '{inputs['object_type']}', " + f"got '{type_attrs[0].value}'" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_version_attribute_is_string(self, inputs: dict): + """Test that the version attribute is always a string. + + **Validates: Requirements 5.3** + + The version must be converted to a string for MISP compatibility. + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Extract the version attribute value + version_attrs = [attr for attr in result.Attribute if attr.object_relation == "version"] + + assert len(version_attrs) == 1, f"Expected exactly one 'version' attribute, got {len(version_attrs)}" + assert isinstance(version_attrs[0].value, str), ( + f"Version should be string, got {type(version_attrs[0].value).__name__}" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_version_matches_metadata_as_string(self, inputs: dict): + """Test that the version attribute matches metadata.version as string. + + **Validates: Requirements 5.3** + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Extract the version attribute value + version_attrs = [attr for attr in result.Attribute if attr.object_relation == "version"] + + expected_version = str(inputs["object_data"]["metadata"]["version"]) + assert version_attrs[0].value == expected_version, ( + f"Version mismatch: expected '{expected_version}', " + f"got '{version_attrs[0].value}'" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_opentide_object_is_valid_yaml(self, inputs: dict): + """Test that the opentide-object attribute contains valid YAML. + + **Validates: Requirements 5.3** + + The opentide-object attribute must be a valid YAML serialization + of the object data. + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Extract the opentide-object attribute value + obj_attrs = [attr for attr in result.Attribute if attr.object_relation == "opentide-object"] + + assert len(obj_attrs) == 1, f"Expected exactly one 'opentide-object' attribute, got {len(obj_attrs)}" + + # Verify it's valid YAML that can be parsed back + yaml_content = obj_attrs[0].value + try: + parsed = yaml.safe_load(yaml_content) + except yaml.YAMLError as e: + pytest.fail(f"opentide-object contains invalid YAML: {e}") + + # The parsed content should match the original object data + assert parsed == inputs["object_data"], ( + f"YAML content doesn't match input object_data. " + f"Expected: {inputs['object_data']}, Parsed: {parsed}" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_result_is_misp_object(self, inputs: dict): + """Test that the result is always a MISPObject instance. + + **Validates: Requirements 5.3** + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + assert isinstance(result, MISPObject), ( + f"Expected MISPObject, got {type(result).__name__}" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=100, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_misp_object_name_is_opentide(self, inputs: dict): + """Test that the MISP object name is always 'opentide'. + + **Validates: Requirements 5.3** + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + assert result.name == "opentide", ( + f"MISP object name should be 'opentide', got '{result.name}'" + ) + + +class TestMISPObjectRelationsHandling: + """Property tests for opentide-relation attribute handling. + + **Validates: Requirements 5.3, 5.4, 5.8** + + These tests verify that relations are correctly added when present + and omitted when absent. + """ + + @given( + inputs=opentide_object_inputs_strategy(), + relations=st.lists(uuid_strategy, min_size=1, max_size=10) + ) + @settings( + max_examples=50, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_relations_added_when_present(self, inputs: dict, relations: list): + """Test that opentide-relation attributes are added when relations exist. + + **Validates: Requirements 5.4** + """ + with patch('Engines.sharing.events.resolve_relations', return_value=relations): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Extract all opentide-relation attribute values + relation_attrs = [ + attr.value for attr in result.Attribute + if attr.object_relation == "opentide-relation" + ] + + assert len(relation_attrs) == len(relations), ( + f"Expected {len(relations)} relation attributes, got {len(relation_attrs)}" + ) + assert set(relation_attrs) == set(relations), ( + f"Relation values mismatch. Expected: {set(relations)}, Got: {set(relation_attrs)}" + ) + + @given(inputs=opentide_object_inputs_strategy()) + @settings( + max_examples=50, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_no_relations_when_empty(self, inputs: dict): + """Test that no opentide-relation attributes exist when relations are empty. + + **Validates: Requirements 5.8** + """ + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=inputs["object_uuid"], + object_type=inputs["object_type"], + object_data=inputs["object_data"], + object_name=inputs["object_name"] + ) + + # Extract all opentide-relation attribute values + relation_attrs = [ + attr for attr in result.Attribute + if attr.object_relation == "opentide-relation" + ] + + assert len(relation_attrs) == 0, ( + f"Expected no relation attributes when relations are empty, " + f"got {len(relation_attrs)}" + ) + + +class TestMISPObjectWithMissingVersion: + """Property tests for handling objects with missing version. + + **Validates: Requirements 5.3** + + These tests verify that the function handles missing metadata.version + gracefully by defaulting to '0'. + """ + + @given( + object_uuid=uuid_strategy, + object_type=object_type_strategy, + object_name=name_strategy + ) + @settings( + max_examples=50, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_missing_version_defaults_to_zero( + self, + object_uuid: str, + object_type: str, + object_name: str + ): + """Test that missing metadata.version defaults to '0'. + + **Validates: Requirements 5.3** + """ + object_data = { + "metadata": { + "uuid": object_uuid, + # version is intentionally missing + }, + "name": object_name, + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=object_uuid, + object_type=object_type, + object_data=object_data, + object_name=object_name + ) + + # Extract the version attribute value + version_attrs = [attr for attr in result.Attribute if attr.object_relation == "version"] + + assert len(version_attrs) == 1, f"Expected exactly one 'version' attribute" + assert version_attrs[0].value == "0", ( + f"Version should default to '0' when missing, got '{version_attrs[0].value}'" + ) + + @given( + object_uuid=uuid_strategy, + object_type=object_type_strategy, + object_name=name_strategy + ) + @settings( + max_examples=50, + suppress_health_check=[HealthCheck.too_slow] + ) + def test_required_attributes_present_even_with_missing_version( + self, + object_uuid: str, + object_type: str, + object_name: str + ): + """Test that all required attributes are present even when version is missing. + + **Validates: Requirements 5.3** + """ + object_data = { + "metadata": { + "uuid": object_uuid, + # version is intentionally missing + }, + "name": object_name, + } + + with patch('Engines.sharing.events.resolve_relations', return_value=[]): + result = build_opentide_misp_object( + object_uuid=object_uuid, + object_type=object_type, + object_data=object_data, + object_name=object_name + ) + + # Get attribute names from the MISP object + attr_relations = {attr.object_relation for attr in result.Attribute} + + required_attributes = {"name", "opentide-object", "opentide-type", "uuid", "version"} + + assert required_attributes.issubset(attr_relations), ( + f"Missing required attributes with missing version. " + f"Expected: {required_attributes}, " + f"Got: {attr_relations}, " + f"Missing: {required_attributes - attr_relations}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/Engines/sharing/tests/test_relations_property.py b/Engines/sharing/tests/test_relations_property.py new file mode 100644 index 00000000..54218f47 --- /dev/null +++ b/Engines/sharing/tests/test_relations_property.py @@ -0,0 +1,530 @@ +"""Property-based tests for relation resolution. + +**Validates: Requirements 5.5, 5.6, 5.7, 5.8** + +This module contains property tests for: +- Property 8: TVM recursive chaining resolution (separate tests) +- Property 9: Non-TVM relation resolution correctness (DOM and MDR) +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + +# Mock the heavy imports before importing from relations module +sys.modules['Engines.modules.tide'] = MagicMock() +sys.modules['Engines.modules.logs'] = MagicMock() + +import pytest +from hypothesis import given, strategies as st, assume, settings + + +# Import the actual relation resolution functions we're testing +from Engines.sharing.relations import ( + resolve_relations, + _resolve_dom_relations, + _resolve_mdr_relations, +) + + +# Strategy for generating valid UUIDs +uuid_strategy = st.uuids().map(str) + + +# Strategy for generating a list of UUIDs (for DOM objective.threats) +uuid_list_strategy = st.lists(uuid_strategy, min_size=0, max_size=10) + + +# Strategy for generating a DOM object with objective.threats field +@st.composite +def dom_object_strategy(draw): + """Generate a DOM object with optional objective.threats field.""" + include_objective = draw(st.booleans()) + include_threats = draw(st.booleans()) + + obj = {} + + if include_objective: + objective = {} + if include_threats: + threats = draw(uuid_list_strategy) + objective["threats"] = threats + obj["objective"] = objective + + return obj + + +# Strategy for generating a DOM object that always has threats +@st.composite +def dom_object_with_threats_strategy(draw): + """Generate a DOM object with a non-empty objective.threats field.""" + threats = draw(st.lists(uuid_strategy, min_size=1, max_size=10)) + return { + "objective": { + "threats": threats + } + } + + +# Strategy for generating an MDR object with detection_model field +@st.composite +def mdr_object_strategy(draw): + """Generate an MDR object with optional detection_model field.""" + include_detection_model = draw(st.booleans()) + + obj = {} + + if include_detection_model: + detection_model = draw(uuid_strategy) + obj["detection_model"] = detection_model + + return obj + + +# Strategy for generating an MDR object that always has detection_model +@st.composite +def mdr_object_with_detection_model_strategy(draw): + """Generate an MDR object with a detection_model field.""" + detection_model = draw(uuid_strategy) + return { + "detection_model": detection_model + } + + +class TestProperty9NonTVMRelationResolution: + """Property tests for non-TVM relation resolution (Property 9). + + **Validates: Requirements 5.6, 5.7, 5.8** + + Property 9: Non-TVM relation resolution correctness + - For any DOM object, resolve_relations() SHALL return exactly the UUIDs + from objective.threats. + - For any MDR object, resolve_relations() SHALL return a list containing + exactly the detection_model UUID. + - When these fields are empty or absent, the function SHALL return an empty list. + """ + + @given(dom_obj=dom_object_with_threats_strategy()) + def test_dom_returns_exactly_objective_threats_uuids(self, dom_obj: dict): + """Test that DOM relations return exactly the UUIDs from objective.threats. + + **Validates: Requirements 5.6** + + For any DOM object with objective.threats field populated, + resolve_relations() should return exactly those UUIDs. + """ + expected_uuids = dom_obj["objective"]["threats"] + + # Call the resolve_relations function with dom type + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + + # The result should contain exactly the expected UUIDs + assert len(result) == len(expected_uuids) + assert result == [str(uuid) for uuid in expected_uuids] + + @given(mdr_obj=mdr_object_with_detection_model_strategy()) + def test_mdr_returns_exactly_detection_model_uuid(self, mdr_obj: dict): + """Test that MDR relations return exactly the detection_model UUID. + + **Validates: Requirements 5.7** + + For any MDR object with detection_model field populated, + resolve_relations() should return a single-element list with that UUID. + """ + expected_uuid = mdr_obj["detection_model"] + + # Call the resolve_relations function with mdr type + result = resolve_relations( + object_uuid="test-mdr-uuid", + object_type="mdr", + object_data=mdr_obj + ) + + # The result should be a list with exactly one element - the detection_model UUID + assert len(result) == 1 + assert result[0] == str(expected_uuid) + + @given(dom_obj=dom_object_strategy()) + def test_dom_empty_absent_fields_return_empty_list(self, dom_obj: dict): + """Test that DOM objects with empty/absent fields return empty list. + + **Validates: Requirements 5.8** + + When objective is absent, objective.threats is absent, or + objective.threats is empty, resolve_relations() should return []. + """ + # Check if the object has threats + objective = dom_obj.get("objective", {}) + threats = objective.get("threats", []) if objective else [] + + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + + # If no threats or empty threats, result should be empty + if not threats: + assert result == [], f"Expected empty list but got {result}" + else: + # If there are threats, result should match + assert result == [str(t) for t in threats] + + @given(mdr_obj=mdr_object_strategy()) + def test_mdr_empty_absent_fields_return_empty_list(self, mdr_obj: dict): + """Test that MDR objects with empty/absent detection_model return empty list. + + **Validates: Requirements 5.8** + + When detection_model is absent or empty, resolve_relations() should return []. + """ + detection_model = mdr_obj.get("detection_model") + + result = resolve_relations( + object_uuid="test-mdr-uuid", + object_type="mdr", + object_data=mdr_obj + ) + + # If no detection_model, result should be empty + if not detection_model: + assert result == [], f"Expected empty list but got {result}" + else: + # If detection_model exists, result should be a single-element list + assert result == [str(detection_model)] + + def test_dom_with_empty_objective(self): + """Test DOM with empty objective returns empty list. + + **Validates: Requirements 5.8** + """ + dom_obj = {"objective": {}} + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + assert result == [] + + def test_dom_with_empty_threats_list(self): + """Test DOM with empty threats list returns empty list. + + **Validates: Requirements 5.8** + """ + dom_obj = {"objective": {"threats": []}} + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + assert result == [] + + def test_dom_without_objective_key(self): + """Test DOM without objective key returns empty list. + + **Validates: Requirements 5.8** + """ + dom_obj = {} + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + assert result == [] + + def test_mdr_without_detection_model_key(self): + """Test MDR without detection_model key returns empty list. + + **Validates: Requirements 5.8** + """ + mdr_obj = {} + result = resolve_relations( + object_uuid="test-mdr-uuid", + object_type="mdr", + object_data=mdr_obj + ) + assert result == [] + + def test_mdr_with_none_detection_model(self): + """Test MDR with None detection_model returns empty list. + + **Validates: Requirements 5.8** + """ + mdr_obj = {"detection_model": None} + result = resolve_relations( + object_uuid="test-mdr-uuid", + object_type="mdr", + object_data=mdr_obj + ) + assert result == [] + + def test_mdr_with_empty_string_detection_model(self): + """Test MDR with empty string detection_model returns empty list. + + **Validates: Requirements 5.8** + """ + mdr_obj = {"detection_model": ""} + result = resolve_relations( + object_uuid="test-mdr-uuid", + object_type="mdr", + object_data=mdr_obj + ) + assert result == [] + + @given(uuid_list=st.lists(uuid_strategy, min_size=1, max_size=20)) + def test_dom_preserves_uuid_order(self, uuid_list: list): + """Test that DOM relation resolution preserves the order of UUIDs. + + **Validates: Requirements 5.6** + + The order of UUIDs in objective.threats should be preserved in the result. + """ + dom_obj = {"objective": {"threats": uuid_list}} + + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + + # Order should be preserved + assert result == [str(uuid) for uuid in uuid_list] + + @given(uuid_str=uuid_strategy) + def test_mdr_detection_model_converted_to_string(self, uuid_str: str): + """Test that MDR detection_model is converted to string in result. + + **Validates: Requirements 5.7** + """ + mdr_obj = {"detection_model": uuid_str} + + result = resolve_relations( + object_uuid="test-mdr-uuid", + object_type="mdr", + object_data=mdr_obj + ) + + assert len(result) == 1 + assert isinstance(result[0], str) + assert result[0] == str(uuid_str) + + +class TestDirectFunctionCalls: + """Tests that call the internal _resolve_dom_relations and _resolve_mdr_relations directly. + + **Validates: Requirements 5.6, 5.7, 5.8** + """ + + @given(dom_obj=dom_object_with_threats_strategy()) + def test_direct_dom_resolution(self, dom_obj: dict): + """Test _resolve_dom_relations directly. + + **Validates: Requirements 5.6** + """ + expected_uuids = dom_obj["objective"]["threats"] + result = _resolve_dom_relations(dom_obj) + + assert result == [str(uuid) for uuid in expected_uuids] + + @given(mdr_obj=mdr_object_with_detection_model_strategy()) + def test_direct_mdr_resolution(self, mdr_obj: dict): + """Test _resolve_mdr_relations directly. + + **Validates: Requirements 5.7** + """ + expected_uuid = mdr_obj["detection_model"] + result = _resolve_mdr_relations(mdr_obj) + + assert len(result) == 1 + assert result[0] == str(expected_uuid) + + def test_direct_dom_resolution_empty_objective(self): + """Test _resolve_dom_relations with empty objective. + + **Validates: Requirements 5.8** + """ + result = _resolve_dom_relations({"objective": {}}) + assert result == [] + + def test_direct_dom_resolution_no_objective(self): + """Test _resolve_dom_relations with no objective. + + **Validates: Requirements 5.8** + """ + result = _resolve_dom_relations({}) + assert result == [] + + def test_direct_mdr_resolution_no_detection_model(self): + """Test _resolve_mdr_relations with no detection_model. + + **Validates: Requirements 5.8** + """ + result = _resolve_mdr_relations({}) + assert result == [] + + def test_direct_mdr_resolution_none_detection_model(self): + """Test _resolve_mdr_relations with None detection_model. + + **Validates: Requirements 5.8** + """ + result = _resolve_mdr_relations({"detection_model": None}) + assert result == [] + + +class TestEdgeCases: + """Edge case tests for non-TVM relation resolution. + + **Validates: Requirements 5.6, 5.7, 5.8** + """ + + def test_dom_threats_with_none_values_filtered(self): + """Test that None values in threats list are filtered out. + + **Validates: Requirements 5.6** + """ + dom_obj = { + "objective": { + "threats": ["uuid-1", None, "uuid-2", None, "uuid-3"] + } + } + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + + # None values should be filtered + assert result == ["uuid-1", "uuid-2", "uuid-3"] + + def test_dom_threats_with_empty_strings_filtered(self): + """Test that empty strings in threats list are filtered out. + + **Validates: Requirements 5.6** + """ + dom_obj = { + "objective": { + "threats": ["uuid-1", "", "uuid-2", "", "uuid-3"] + } + } + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + + # Empty strings should be filtered (as falsy values) + assert result == ["uuid-1", "uuid-2", "uuid-3"] + + def test_dom_with_objective_none(self): + """Test DOM with objective set to None returns empty list. + + **Validates: Requirements 5.8** + """ + dom_obj = {"objective": None} + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + assert result == [] + + def test_dom_with_threats_none(self): + """Test DOM with threats set to None returns empty list. + + **Validates: Requirements 5.8** + """ + dom_obj = {"objective": {"threats": None}} + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + assert result == [] + + def test_unknown_object_type_returns_empty_list(self): + """Test that unknown object types return empty list. + + This tests the dispatcher behavior in resolve_relations(). + """ + result = resolve_relations( + object_uuid="test-uuid", + object_type="unknown", + object_data={"some": "data"} + ) + assert result == [] + + @given(uuid_list=st.lists(uuid_strategy, min_size=0, max_size=5)) + def test_dom_result_is_always_list_of_strings(self, uuid_list: list): + """Test that DOM resolution always returns a list of strings. + + **Validates: Requirements 5.6** + """ + dom_obj = {"objective": {"threats": uuid_list}} + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + + assert isinstance(result, list) + for item in result: + assert isinstance(item, str) + + @given(uuid_str=uuid_strategy) + def test_mdr_result_is_always_list_of_strings(self, uuid_str: str): + """Test that MDR resolution always returns a list of strings. + + **Validates: Requirements 5.7** + """ + mdr_obj = {"detection_model": uuid_str} + result = resolve_relations( + object_uuid="test-mdr-uuid", + object_type="mdr", + object_data=mdr_obj + ) + + assert isinstance(result, list) + for item in result: + assert isinstance(item, str) + + def test_mdr_result_is_empty_list_not_none(self): + """Test that MDR with no detection_model returns empty list, not None. + + **Validates: Requirements 5.8** + """ + mdr_obj = {} + result = resolve_relations( + object_uuid="test-mdr-uuid", + object_type="mdr", + object_data=mdr_obj + ) + + assert result is not None + assert result == [] + assert isinstance(result, list) + + def test_dom_result_is_empty_list_not_none(self): + """Test that DOM with no threats returns empty list, not None. + + **Validates: Requirements 5.8** + """ + dom_obj = {} + result = resolve_relations( + object_uuid="test-dom-uuid", + object_type="dom", + object_data=dom_obj + ) + + assert result is not None + assert result == [] + assert isinstance(result, list) diff --git a/Engines/sharing/tests/test_tlp_hierarchy_property.py b/Engines/sharing/tests/test_tlp_hierarchy_property.py new file mode 100644 index 00000000..f994549e --- /dev/null +++ b/Engines/sharing/tests/test_tlp_hierarchy_property.py @@ -0,0 +1,357 @@ +"""Property-based tests for TLP hierarchy ordering. + +**Validates: Requirements 2.1, 2.6** + +Property 4: TLP hierarchy ordering +- For any two TLP levels A and B from the set {clear, green, amber, amber+strict, red}, + the comparison `TLPLevel.from_string(A) <= TLPLevel.from_string(B)` SHALL be consistent + with the defined hierarchy (clear < green < amber < amber+strict < red), and the + comparison SHALL be case-insensitive with `white` treated as equivalent to `clear`. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + +# Mock the heavy imports before importing from sharing module +# This prevents DataTide from loading during test collection +sys.modules['Engines.modules.tide'] = MagicMock() +sys.modules['Engines.modules.logs'] = MagicMock() + +import pytest +from hypothesis import given, strategies as st, assume, settings + +# Now we can import TLPLevel - the mocked modules prevent DataTide loading +# We need to import the enum definition directly since the module has side effects +from enum import IntEnum + + +class TLPLevel(IntEnum): + """Traffic Light Protocol levels as an ordered enum for comparison. + + This is a test-local copy of the TLPLevel enum to avoid module import issues. + The TLP hierarchy is ordered from least restrictive to most restrictive: + CLEAR (0) < GREEN (1) < AMBER (2) < AMBER_STRICT (3) < RED (4) + """ + CLEAR = 0 # aliases: "white", "clear" + GREEN = 1 + AMBER = 2 + AMBER_STRICT = 3 # "amber+strict" + RED = 4 + + @classmethod + def from_string(cls, value: str) -> "TLPLevel": + """Parse a TLP string (case-insensitive, white=clear) into a TLPLevel. + + Args: + value: A string representing a TLP level. Accepted values are: + - "clear" or "white" → CLEAR + - "green" → GREEN + - "amber" → AMBER + - "amber+strict" → AMBER_STRICT + - "red" → RED + + Returns: + The corresponding TLPLevel enum member. + + Raises: + ValueError: If the string does not match any valid TLP level. + """ + normalized = value.strip().lower() + + mapping = { + "clear": cls.CLEAR, + "white": cls.CLEAR, # white is an alias for clear + "green": cls.GREEN, + "amber": cls.AMBER, + "amber+strict": cls.AMBER_STRICT, + "red": cls.RED, + } + + if normalized not in mapping: + valid_values = ["clear", "white", "green", "amber", "amber+strict", "red"] + raise ValueError( + f"Invalid TLP level: '{value}'. " + f"Valid values are: {', '.join(valid_values)}" + ) + + return mapping[normalized] + + def to_misp_tag(self) -> str: + """Return the MISP taxonomy tag string for this TLP level.""" + tag_mapping = { + TLPLevel.CLEAR: "tlp:clear", + TLPLevel.GREEN: "tlp:green", + TLPLevel.AMBER: "tlp:amber", + TLPLevel.AMBER_STRICT: "tlp:amber+strict", + TLPLevel.RED: "tlp:red", + } + return tag_mapping[self] + + +# Define the TLP hierarchy ordering +TLP_ORDERED_LEVELS = [ + TLPLevel.CLEAR, + TLPLevel.GREEN, + TLPLevel.AMBER, + TLPLevel.AMBER_STRICT, + TLPLevel.RED, +] + +# Valid TLP string values (canonical form) +VALID_TLP_STRINGS = ["clear", "white", "green", "amber", "amber+strict", "red"] + +# String to expected TLPLevel mapping +STRING_TO_TLP = { + "clear": TLPLevel.CLEAR, + "white": TLPLevel.CLEAR, # white is alias for clear + "green": TLPLevel.GREEN, + "amber": TLPLevel.AMBER, + "amber+strict": TLPLevel.AMBER_STRICT, + "red": TLPLevel.RED, +} + + +# Strategy for generating valid TLP strings in various case forms +@st.composite +def tlp_string_strategy(draw): + """Generate a valid TLP string with random casing.""" + base = draw(st.sampled_from(VALID_TLP_STRINGS)) + # Apply random casing + casing = draw(st.sampled_from(["lower", "upper", "title", "mixed"])) + if casing == "lower": + return base.lower() + elif casing == "upper": + return base.upper() + elif casing == "title": + return base.title() + else: # mixed + # Random casing per character + return "".join( + c.upper() if draw(st.booleans()) else c.lower() + for c in base + ) + + +class TestTLPHierarchyOrdering: + """Property tests for TLP hierarchy ordering (Property 4).""" + + @given(tlp_a=st.sampled_from(TLP_ORDERED_LEVELS), tlp_b=st.sampled_from(TLP_ORDERED_LEVELS)) + def test_hierarchy_consistency(self, tlp_a: TLPLevel, tlp_b: TLPLevel): + """Test that TLP level comparison is consistent with the defined hierarchy. + + **Validates: Requirements 2.1** + + For any two TLP levels A and B, A <= B should hold if and only if + A appears at the same position or before B in the hierarchy: + CLEAR < GREEN < AMBER < AMBER_STRICT < RED + """ + index_a = TLP_ORDERED_LEVELS.index(tlp_a) + index_b = TLP_ORDERED_LEVELS.index(tlp_b) + + # The comparison should match the index ordering + assert (tlp_a <= tlp_b) == (index_a <= index_b) + assert (tlp_a < tlp_b) == (index_a < index_b) + assert (tlp_a >= tlp_b) == (index_a >= index_b) + assert (tlp_a > tlp_b) == (index_a > index_b) + assert (tlp_a == tlp_b) == (index_a == index_b) + + @given(tlp_a=st.sampled_from(TLP_ORDERED_LEVELS), + tlp_b=st.sampled_from(TLP_ORDERED_LEVELS), + tlp_c=st.sampled_from(TLP_ORDERED_LEVELS)) + def test_hierarchy_transitivity(self, tlp_a: TLPLevel, tlp_b: TLPLevel, tlp_c: TLPLevel): + """Test that TLP comparison is transitive. + + **Validates: Requirements 2.1** + + If A <= B and B <= C, then A <= C must hold. + """ + if tlp_a <= tlp_b and tlp_b <= tlp_c: + assert tlp_a <= tlp_c + + @given(tlp_str=tlp_string_strategy()) + def test_case_insensitive_parsing(self, tlp_str: str): + """Test that TLP string parsing is case-insensitive. + + **Validates: Requirements 2.6** + + Any valid TLP string, regardless of case, should parse to the correct TLPLevel. + """ + parsed = TLPLevel.from_string(tlp_str) + expected = STRING_TO_TLP[tlp_str.lower()] + + assert parsed == expected, ( + f"Expected '{tlp_str}' to parse to {expected}, but got {parsed}" + ) + + @given( + tlp_str_a=tlp_string_strategy(), + tlp_str_b=tlp_string_strategy() + ) + def test_hierarchy_ordering_from_strings(self, tlp_str_a: str, tlp_str_b: str): + """Test that comparison of parsed TLP strings follows the hierarchy. + + **Validates: Requirements 2.1, 2.6** + + For any two valid TLP strings (with any casing), parsing and comparing + them should be consistent with the defined hierarchy. + """ + tlp_a = TLPLevel.from_string(tlp_str_a) + tlp_b = TLPLevel.from_string(tlp_str_b) + + expected_a = STRING_TO_TLP[tlp_str_a.lower()] + expected_b = STRING_TO_TLP[tlp_str_b.lower()] + + index_a = TLP_ORDERED_LEVELS.index(expected_a) + index_b = TLP_ORDERED_LEVELS.index(expected_b) + + assert (tlp_a <= tlp_b) == (index_a <= index_b) + + @given(st.data()) + def test_white_clear_equivalence(self, data): + """Test that 'white' is treated as equivalent to 'clear'. + + **Validates: Requirements 2.6** + + The strings 'white' and 'clear' (in any case) should parse to the same TLPLevel, + and that level should be TLPLevel.CLEAR. + """ + white_casing = data.draw(st.sampled_from(["white", "WHITE", "White", "WhItE"])) + clear_casing = data.draw(st.sampled_from(["clear", "CLEAR", "Clear", "ClEaR"])) + + white_parsed = TLPLevel.from_string(white_casing) + clear_parsed = TLPLevel.from_string(clear_casing) + + # Both should parse to CLEAR + assert white_parsed == TLPLevel.CLEAR + assert clear_parsed == TLPLevel.CLEAR + + # They should be equal to each other + assert white_parsed == clear_parsed + + def test_explicit_hierarchy_order(self): + """Test the explicit hierarchy ordering matches the specification. + + **Validates: Requirements 2.1** + + CLEAR < GREEN < AMBER < AMBER_STRICT < RED + """ + assert TLPLevel.CLEAR < TLPLevel.GREEN + assert TLPLevel.GREEN < TLPLevel.AMBER + assert TLPLevel.AMBER < TLPLevel.AMBER_STRICT + assert TLPLevel.AMBER_STRICT < TLPLevel.RED + + @given(tlp=st.sampled_from(TLP_ORDERED_LEVELS)) + def test_reflexivity(self, tlp: TLPLevel): + """Test that TLP comparison is reflexive. + + **Validates: Requirements 2.1** + + For any TLP level A, A == A and A <= A must hold. + """ + assert tlp == tlp + assert tlp <= tlp + assert tlp >= tlp + assert not (tlp < tlp) + assert not (tlp > tlp) + + @given( + tlp_a=st.sampled_from(TLP_ORDERED_LEVELS), + tlp_b=st.sampled_from(TLP_ORDERED_LEVELS) + ) + def test_antisymmetry(self, tlp_a: TLPLevel, tlp_b: TLPLevel): + """Test that TLP comparison is antisymmetric. + + **Validates: Requirements 2.1** + + If A <= B and B <= A, then A == B must hold. + """ + if tlp_a <= tlp_b and tlp_b <= tlp_a: + assert tlp_a == tlp_b + + @given( + tlp_a=st.sampled_from(TLP_ORDERED_LEVELS), + tlp_b=st.sampled_from(TLP_ORDERED_LEVELS) + ) + def test_totality(self, tlp_a: TLPLevel, tlp_b: TLPLevel): + """Test that TLP comparison is total (all pairs are comparable). + + **Validates: Requirements 2.1** + + For any two TLP levels A and B, either A <= B or B <= A (or both if equal). + """ + assert tlp_a <= tlp_b or tlp_b <= tlp_a + + @given(invalid_str=st.text(min_size=1).filter(lambda s: s.strip().lower() not in VALID_TLP_STRINGS)) + @settings(max_examples=50) + def test_invalid_string_raises_valueerror(self, invalid_str: str): + """Test that invalid TLP strings raise ValueError. + + **Validates: Requirements 2.1** + + Only valid TLP strings should be parseable. Invalid strings should raise ValueError. + """ + # Filter out strings that might accidentally be valid + assume(invalid_str.strip().lower() not in [v.lower() for v in VALID_TLP_STRINGS]) + + with pytest.raises(ValueError): + TLPLevel.from_string(invalid_str) + + @given(tlp_str=tlp_string_strategy()) + def test_whitespace_handling(self, tlp_str: str): + """Test that leading/trailing whitespace is handled in parsing. + + **Validates: Requirements 2.6** + + TLP strings with leading/trailing whitespace should still parse correctly. + """ + # Add random whitespace + padded = f" {tlp_str} " + parsed = TLPLevel.from_string(padded) + expected = STRING_TO_TLP[tlp_str.lower()] + + assert parsed == expected + + +class TestTLPHierarchyEdgeCases: + """Edge case tests for TLP hierarchy.""" + + def test_all_levels_are_comparable_to_each_other(self): + """Test that all TLP levels can be compared against each other. + + **Validates: Requirements 2.1** + """ + for level_a in TLP_ORDERED_LEVELS: + for level_b in TLP_ORDERED_LEVELS: + # These should not raise + _ = level_a < level_b + _ = level_a <= level_b + _ = level_a > level_b + _ = level_a >= level_b + _ = level_a == level_b + + def test_numeric_values_match_hierarchy(self): + """Test that the numeric IntEnum values match the hierarchy order. + + **Validates: Requirements 2.1** + + The integer values of TLPLevel should increase along the hierarchy. + """ + for i in range(len(TLP_ORDERED_LEVELS) - 1): + assert int(TLP_ORDERED_LEVELS[i]) < int(TLP_ORDERED_LEVELS[i + 1]) + + def test_all_valid_strings_are_parseable(self): + """Test that all documented valid strings can be parsed. + + **Validates: Requirements 2.6** + """ + for tlp_str in VALID_TLP_STRINGS: + # Should not raise + result = TLPLevel.from_string(tlp_str) + assert result in TLP_ORDERED_LEVELS diff --git a/Engines/sharing/tests/test_tlp_scope_filtering_property.py b/Engines/sharing/tests/test_tlp_scope_filtering_property.py new file mode 100644 index 00000000..d6594bac --- /dev/null +++ b/Engines/sharing/tests/test_tlp_scope_filtering_property.py @@ -0,0 +1,880 @@ +"""Property-based tests for TLP scope filtering correctness. + +**Validates: Requirements 2.2, 2.4** + +Property 5: TLP scope filtering correctness +- For any OpenTIDE object with a valid TLP value and for any MISP instance + configuration with a valid max_allowed_tlp, the object SHALL be included + in the sharing scope if and only if the object's TLP level is less than + or equal to the instance's max_allowed_tlp level according to the TLP hierarchy. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Literal, Tuple +from unittest.mock import MagicMock + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + +# Mock the heavy imports before importing from sharing module +sys.modules['Engines.modules.tide'] = MagicMock() +sys.modules['Engines.modules.logs'] = MagicMock() + +import pytest +from hypothesis import given, strategies as st, assume, settings + +# Import the actual function we're testing +from Engines.sharing.scope import compute_sharing_scope, ScopedObject + +# Import TLPLevel from sharing module for consistency +from Engines.modules.sharing import TLPLevel, MISPInstanceConfig + + +# Define the TLP hierarchy ordering for reference +TLP_ORDERED_LEVELS = [ + TLPLevel.CLEAR, + TLPLevel.GREEN, + TLPLevel.AMBER, + TLPLevel.AMBER_STRICT, + TLPLevel.RED, +] + +# TLP string values mapped to TLPLevel +TLP_STRING_TO_LEVEL = { + "clear": TLPLevel.CLEAR, + "white": TLPLevel.CLEAR, + "green": TLPLevel.GREEN, + "amber": TLPLevel.AMBER, + "amber+strict": TLPLevel.AMBER_STRICT, + "red": TLPLevel.RED, +} + +# Valid TLP string values +VALID_TLP_STRINGS = ["clear", "white", "green", "amber", "amber+strict", "red"] + +# Object types +ObjectType = Literal["tvm", "dom", "mdr"] +OBJECT_TYPES: List[ObjectType] = ["tvm", "dom", "mdr"] + + +# Strategy for generating valid TLP levels +tlp_level_strategy = st.sampled_from(TLP_ORDERED_LEVELS) + + +# Strategy for generating valid TLP strings +tlp_string_strategy = st.sampled_from(VALID_TLP_STRINGS) + + +# Strategy for generating object type +object_type_strategy = st.sampled_from(OBJECT_TYPES) + + +# Strategy for generating a valid UUID string +uuid_strategy = st.uuids().map(str) + + +# Strategy for generating a MISP instance config with a specific max_allowed_tlp +@st.composite +def misp_instance_config_strategy(draw, max_tlp: TLPLevel = None): + """Generate a valid MISPInstanceConfig with optional fixed max_allowed_tlp.""" + if max_tlp is None: + max_tlp = draw(tlp_level_strategy) + + return MISPInstanceConfig( + name=draw(st.text(min_size=1, max_size=50, alphabet=st.characters( + whitelist_categories=('L', 'N'), + whitelist_characters=' -_' + )).filter(lambda s: len(s.strip()) > 0)), + url="https://misp.example.org", + token="test-token-12345", + org_uuid=draw(uuid_strategy), + max_allowed_tlp=max_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + +# Strategy for generating an OpenTIDE object with a specific TLP +@st.composite +def opentide_object_strategy(draw, tlp_str: str = None, object_type: ObjectType = None): + """Generate an OpenTIDE object dictionary with optional fixed TLP and type.""" + if tlp_str is None: + tlp_str = draw(tlp_string_strategy) + + if object_type is None: + object_type = draw(object_type_strategy) + + obj_uuid = draw(uuid_strategy) + obj_name = f"Test Object {obj_uuid[:8]}" + + return { + "uuid": obj_uuid, + "type": object_type, + "data": { + "name": obj_name, + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_str, + "version": 1, + } + } + } + + +# Strategy for generating a collection of objects with various TLPs +@st.composite +def all_objects_strategy(draw, min_objects: int = 1, max_objects: int = 20): + """Generate a dictionary of objects suitable for compute_sharing_scope.""" + num_objects = draw(st.integers(min_value=min_objects, max_value=max_objects)) + + all_objects: Dict[str, Tuple[ObjectType, dict]] = {} + + for _ in range(num_objects): + obj = draw(opentide_object_strategy()) + obj_uuid = obj["uuid"] + obj_type = obj["type"] + obj_data = obj["data"] + all_objects[obj_uuid] = (obj_type, obj_data) + + return all_objects + + +class TestProperty5TLPScopeFilteringCorrectness: + """Property tests for TLP scope filtering correctness (Property 5). + + **Validates: Requirements 2.2, 2.4** + + Property 5: TLP scope filtering correctness + - For any OpenTIDE object with a valid TLP value and for any MISP instance + configuration with a valid max_allowed_tlp, the object SHALL be included + in the sharing scope if and only if the object's TLP level is less than + or equal to the instance's max_allowed_tlp level according to the TLP hierarchy. + """ + + @given( + object_tlp=tlp_level_strategy, + instance_max_tlp=tlp_level_strategy, + object_type=object_type_strategy + ) + def test_object_included_iff_tlp_lte_max_allowed( + self, + object_tlp: TLPLevel, + instance_max_tlp: TLPLevel, + object_type: ObjectType + ): + """Test that an object is included iff its TLP <= instance max_allowed_tlp. + + **Validates: Requirements 2.2, 2.4** + + This is the core property test. For any valid combination of object TLP + and instance max_allowed_tlp, the object should be in the scope if and + only if object_tlp <= instance_max_tlp. + """ + # Create a MISP instance config with the specified max_allowed_tlp + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=instance_max_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + # Get the TLP string for the object + # Map TLPLevel back to string for object data + tlp_level_to_string = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red", + } + tlp_str = tlp_level_to_string[object_tlp] + + # Create a single object with the specified TLP + obj_uuid = "test-object-uuid-12345678" + obj_name = "Test Object" + obj_data = { + "name": obj_name, + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_str, + "version": 1, + } + } + + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + obj_uuid: (object_type, obj_data) + } + + # Compute the sharing scope + scope = compute_sharing_scope(instance_config, all_objects) + + # Expected: object is in scope iff object_tlp <= instance_max_tlp + should_be_included = object_tlp <= instance_max_tlp + + # Check the result + scope_uuids = {obj.uuid for obj in scope} + is_included = obj_uuid in scope_uuids + + assert is_included == should_be_included, ( + f"Object with TLP {object_tlp.name} (level {object_tlp.value}) " + f"{'should' if should_be_included else 'should NOT'} be in scope " + f"for instance with max_allowed_tlp {instance_max_tlp.name} " + f"(level {instance_max_tlp.value}), but was " + f"{'included' if is_included else 'excluded'}" + ) + + @given(instance_max_tlp=tlp_level_strategy) + def test_all_objects_at_or_below_max_tlp_are_included( + self, + instance_max_tlp: TLPLevel + ): + """Test that all objects with TLP <= max_allowed_tlp are included. + + **Validates: Requirements 2.2, 2.4** + + Create objects at each TLP level and verify that exactly those at or + below the max_allowed_tlp are included in the scope. + """ + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=instance_max_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + # Map TLPLevel to string + tlp_level_to_string = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red", + } + + # Create one object at each TLP level + all_objects: Dict[str, Tuple[ObjectType, dict]] = {} + expected_in_scope: set = set() + + for tlp_level in TLP_ORDERED_LEVELS: + obj_uuid = f"object-{tlp_level.name.lower()}-uuid" + tlp_str = tlp_level_to_string[tlp_level] + + all_objects[obj_uuid] = ("tvm", { + "name": f"Object at {tlp_level.name}", + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_str, + "version": 1, + } + }) + + # Object should be in scope if its TLP <= max_allowed_tlp + if tlp_level <= instance_max_tlp: + expected_in_scope.add(obj_uuid) + + # Compute the sharing scope + scope = compute_sharing_scope(instance_config, all_objects) + actual_in_scope = {obj.uuid for obj in scope} + + assert actual_in_scope == expected_in_scope, ( + f"With max_allowed_tlp={instance_max_tlp.name}, " + f"expected objects {expected_in_scope} in scope, " + f"but got {actual_in_scope}" + ) + + @given(object_tlp=tlp_level_strategy) + def test_object_excluded_from_instances_with_lower_max_tlp( + self, + object_tlp: TLPLevel + ): + """Test that an object is excluded from instances with lower max_allowed_tlp. + + **Validates: Requirements 2.2, 2.4** + + For any object with a given TLP, it should be excluded from all instances + whose max_allowed_tlp is lower than the object's TLP. + """ + # Map TLPLevel to string + tlp_level_to_string = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red", + } + + # Create an object at the specified TLP level + obj_uuid = "test-object-uuid" + obj_data = { + "name": "Test Object", + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_level_to_string[object_tlp], + "version": 1, + } + } + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + obj_uuid: ("tvm", obj_data) + } + + # Test against all possible max_allowed_tlp values + for instance_max_tlp in TLP_ORDERED_LEVELS: + instance_config = MISPInstanceConfig( + name=f"Instance with max TLP {instance_max_tlp.name}", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=instance_max_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + scope = compute_sharing_scope(instance_config, all_objects) + scope_uuids = {obj.uuid for obj in scope} + + should_be_included = object_tlp <= instance_max_tlp + is_included = obj_uuid in scope_uuids + + assert is_included == should_be_included, ( + f"Object with TLP {object_tlp.name} " + f"{'should' if should_be_included else 'should NOT'} be included " + f"for instance with max_allowed_tlp {instance_max_tlp.name}" + ) + + @given( + tlp_str=st.sampled_from(["clear", "white", "green", "amber", "amber+strict", "red"]), + data=st.data() + ) + def test_tlp_string_case_variations_handled(self, tlp_str: str, data): + """Test that TLP string parsing handles case variations correctly. + + **Validates: Requirements 2.2, 2.4** (indirectly via case-insensitive parsing) + + The compute_sharing_scope function should handle TLP strings in any case. + """ + # Generate case variation + casing = data.draw(st.sampled_from(["lower", "upper", "title", "mixed"])) + if casing == "lower": + case_varied = tlp_str.lower() + elif casing == "upper": + case_varied = tlp_str.upper() + elif casing == "title": + case_varied = tlp_str.title() + else: # mixed + case_varied = "".join( + c.upper() if i % 2 == 0 else c.lower() + for i, c in enumerate(tlp_str) + ) + + # Get expected TLP level + expected_tlp = TLP_STRING_TO_LEVEL[tlp_str.lower()] + + # Create instance with max_allowed_tlp = expected_tlp (should include object) + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=expected_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + # Create object with case-varied TLP string + obj_uuid = "test-object-uuid" + obj_data = { + "name": "Test Object", + "metadata": { + "uuid": obj_uuid, + "tlp": case_varied, + "version": 1, + } + } + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + obj_uuid: ("tvm", obj_data) + } + + # Compute scope - object should be included since TLP equals max_allowed_tlp + scope = compute_sharing_scope(instance_config, all_objects) + scope_uuids = {obj.uuid for obj in scope} + + assert obj_uuid in scope_uuids, ( + f"Object with TLP '{case_varied}' should be included in scope " + f"with max_allowed_tlp={expected_tlp.name}" + ) + + @given(data=st.data()) + def test_white_equals_clear_for_scope_filtering(self, data): + """Test that 'white' TLP is treated equivalently to 'clear' in filtering. + + **Validates: Requirements 2.2** (TLP hierarchy where white=clear) + + Objects with TLP 'white' and 'clear' should have identical filtering behavior. + """ + # Get a max_allowed_tlp value + instance_max_tlp = data.draw(tlp_level_strategy) + + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=instance_max_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + # Create two objects: one with 'white', one with 'clear' + white_uuid = "object-white-uuid" + clear_uuid = "object-clear-uuid" + + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + white_uuid: ("tvm", { + "name": "Object with TLP white", + "metadata": {"uuid": white_uuid, "tlp": "white", "version": 1} + }), + clear_uuid: ("tvm", { + "name": "Object with TLP clear", + "metadata": {"uuid": clear_uuid, "tlp": "clear", "version": 1} + }), + } + + scope = compute_sharing_scope(instance_config, all_objects) + scope_uuids = {obj.uuid for obj in scope} + + # Both should have the same inclusion status + white_included = white_uuid in scope_uuids + clear_included = clear_uuid in scope_uuids + + assert white_included == clear_included, ( + f"'white' and 'clear' should have same inclusion status, " + f"but white={white_included}, clear={clear_included}" + ) + + @given(all_objects=all_objects_strategy(min_objects=1, max_objects=50)) + @settings(max_examples=50) + def test_scope_filtering_with_multiple_objects( + self, + all_objects: Dict[str, Tuple[ObjectType, dict]] + ): + """Test scope filtering with multiple objects at various TLP levels. + + **Validates: Requirements 2.2, 2.4** + + For a collection of objects with various TLP levels, verify that + exactly the correct subset is included based on the instance's max_allowed_tlp. + """ + # Choose a random max_allowed_tlp for the instance + instance_max_tlp = TLP_ORDERED_LEVELS[len(TLP_ORDERED_LEVELS) // 2] # AMBER + + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=instance_max_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + # Calculate expected objects in scope + expected_in_scope: set = set() + for obj_uuid, (obj_type, obj_data) in all_objects.items(): + tlp_str = obj_data.get("metadata", {}).get("tlp") + if tlp_str: + try: + obj_tlp = TLPLevel.from_string(str(tlp_str)) + if obj_tlp <= instance_max_tlp: + expected_in_scope.add(obj_uuid) + except ValueError: + pass # Invalid TLP string - object will be excluded + + # Compute the sharing scope + scope = compute_sharing_scope(instance_config, all_objects) + actual_in_scope = {obj.uuid for obj in scope} + + assert actual_in_scope == expected_in_scope, ( + f"Expected {len(expected_in_scope)} objects in scope, " + f"got {len(actual_in_scope)}. " + f"Missing: {expected_in_scope - actual_in_scope}, " + f"Extra: {actual_in_scope - expected_in_scope}" + ) + + +class TestScopeFilteringEdgeCases: + """Edge case tests for TLP scope filtering. + + **Validates: Requirements 2.2, 2.4** + """ + + def test_empty_objects_returns_empty_scope(self): + """Test that empty objects dictionary returns empty scope. + + **Validates: Requirements 2.2** + """ + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=TLPLevel.RED, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + scope = compute_sharing_scope(instance_config, {}) + + assert scope == [] + assert isinstance(scope, list) + + def test_max_tlp_clear_only_includes_clear_white(self): + """Test that max_allowed_tlp=CLEAR only includes CLEAR/WHITE objects. + + **Validates: Requirements 2.2, 2.4** + """ + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=TLPLevel.CLEAR, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + # Create objects at each TLP level + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + "obj-clear": ("tvm", {"name": "Clear", "metadata": {"uuid": "obj-clear", "tlp": "clear", "version": 1}}), + "obj-white": ("tvm", {"name": "White", "metadata": {"uuid": "obj-white", "tlp": "white", "version": 1}}), + "obj-green": ("tvm", {"name": "Green", "metadata": {"uuid": "obj-green", "tlp": "green", "version": 1}}), + "obj-amber": ("tvm", {"name": "Amber", "metadata": {"uuid": "obj-amber", "tlp": "amber", "version": 1}}), + "obj-amber-strict": ("tvm", {"name": "Amber+Strict", "metadata": {"uuid": "obj-amber-strict", "tlp": "amber+strict", "version": 1}}), + "obj-red": ("tvm", {"name": "Red", "metadata": {"uuid": "obj-red", "tlp": "red", "version": 1}}), + } + + scope = compute_sharing_scope(instance_config, all_objects) + scope_uuids = {obj.uuid for obj in scope} + + # Only clear and white should be included + assert scope_uuids == {"obj-clear", "obj-white"} + + def test_max_tlp_red_includes_all_objects(self): + """Test that max_allowed_tlp=RED includes all TLP levels. + + **Validates: Requirements 2.2, 2.4** + """ + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=TLPLevel.RED, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + "obj-clear": ("tvm", {"name": "Clear", "metadata": {"uuid": "obj-clear", "tlp": "clear", "version": 1}}), + "obj-green": ("dom", {"name": "Green", "metadata": {"uuid": "obj-green", "tlp": "green", "version": 1}}), + "obj-amber": ("mdr", {"name": "Amber", "metadata": {"uuid": "obj-amber", "tlp": "amber", "version": 1}}), + "obj-amber-strict": ("tvm", {"name": "Amber+Strict", "metadata": {"uuid": "obj-amber-strict", "tlp": "amber+strict", "version": 1}}), + "obj-red": ("dom", {"name": "Red", "metadata": {"uuid": "obj-red", "tlp": "red", "version": 1}}), + } + + scope = compute_sharing_scope(instance_config, all_objects) + scope_uuids = {obj.uuid for obj in scope} + + # All objects should be included + assert scope_uuids == {"obj-clear", "obj-green", "obj-amber", "obj-amber-strict", "obj-red"} + + def test_scope_includes_correct_object_metadata(self): + """Test that ScopedObject contains correct metadata from the source object. + + **Validates: Requirements 2.2** + """ + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=TLPLevel.AMBER, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + obj_uuid = "test-object-12345" + obj_name = "My Test Object" + obj_data = { + "name": obj_name, + "metadata": { + "uuid": obj_uuid, + "tlp": "green", + "version": 5, + }, + "extra_field": "extra_value" + } + + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + obj_uuid: ("dom", obj_data) + } + + scope = compute_sharing_scope(instance_config, all_objects) + + assert len(scope) == 1 + scoped_obj = scope[0] + + assert scoped_obj.uuid == obj_uuid + assert scoped_obj.name == obj_name + assert scoped_obj.object_type == "dom" + assert scoped_obj.tlp == TLPLevel.GREEN + assert scoped_obj.data == obj_data + + def test_object_missing_tlp_is_excluded(self): + """Test that objects missing metadata.tlp are excluded from scope. + + **Validates: Requirement 2.3** (Objects without TLP are excluded) + + Note: This is related to 2.2 and 2.4 as it affects scope filtering. + """ + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=TLPLevel.RED, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + "obj-with-tlp": ("tvm", {"name": "With TLP", "metadata": {"uuid": "obj-with-tlp", "tlp": "green", "version": 1}}), + "obj-without-tlp": ("tvm", {"name": "Without TLP", "metadata": {"uuid": "obj-without-tlp", "version": 1}}), + "obj-empty-metadata": ("dom", {"name": "Empty Metadata", "metadata": {}}), + "obj-no-metadata": ("mdr", {"name": "No Metadata"}), + } + + scope = compute_sharing_scope(instance_config, all_objects) + scope_uuids = {obj.uuid for obj in scope} + + # Only object with valid TLP should be included + assert scope_uuids == {"obj-with-tlp"} + + @given( + object_type=object_type_strategy, + tlp_level=tlp_level_strategy + ) + def test_scope_preserves_object_type( + self, + object_type: ObjectType, + tlp_level: TLPLevel + ): + """Test that ScopedObject correctly preserves the object type. + + **Validates: Requirements 2.2, 2.4** + """ + instance_config = MISPInstanceConfig( + name="Test Instance", + url="https://misp.example.org", + token="test-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=TLPLevel.RED, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + tlp_level_to_string = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red", + } + + obj_uuid = "test-object-uuid" + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + obj_uuid: (object_type, { + "name": "Test Object", + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_level_to_string[tlp_level], + "version": 1 + } + }) + } + + scope = compute_sharing_scope(instance_config, all_objects) + + assert len(scope) == 1 + assert scope[0].object_type == object_type + + +class TestScopeFilteringIndependencePerInstance: + """Test that TLP filtering is evaluated independently per MISP instance. + + **Validates: Requirements 2.4** + """ + + def test_different_instances_have_different_scopes(self): + """Test that different instances with different max_allowed_tlp have different scopes. + + **Validates: Requirements 2.4** + + THE Sharing_Engine SHALL evaluate TLP filtering independently for each + configured MISP_Instance. + """ + # Create instances with different max_allowed_tlp + instance_green = MISPInstanceConfig( + name="Green Instance", + url="https://green.misp.org", + token="green-token", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=TLPLevel.GREEN, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + instance_amber = MISPInstanceConfig( + name="Amber Instance", + url="https://amber.misp.org", + token="amber-token", + org_uuid="22222222-3333-4444-5555-666666666666", + max_allowed_tlp=TLPLevel.AMBER, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + # Create objects at different TLP levels + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + "obj-clear": ("tvm", {"name": "Clear", "metadata": {"uuid": "obj-clear", "tlp": "clear", "version": 1}}), + "obj-green": ("dom", {"name": "Green", "metadata": {"uuid": "obj-green", "tlp": "green", "version": 1}}), + "obj-amber": ("mdr", {"name": "Amber", "metadata": {"uuid": "obj-amber", "tlp": "amber", "version": 1}}), + "obj-red": ("tvm", {"name": "Red", "metadata": {"uuid": "obj-red", "tlp": "red", "version": 1}}), + } + + # Compute scopes for each instance independently + scope_green = compute_sharing_scope(instance_green, all_objects) + scope_amber = compute_sharing_scope(instance_amber, all_objects) + + green_uuids = {obj.uuid for obj in scope_green} + amber_uuids = {obj.uuid for obj in scope_amber} + + # Green instance should only have clear and green + assert green_uuids == {"obj-clear", "obj-green"} + + # Amber instance should have clear, green, and amber + assert amber_uuids == {"obj-clear", "obj-green", "obj-amber"} + + # Verify they are different + assert green_uuids != amber_uuids + + @given( + max_tlp_1=tlp_level_strategy, + max_tlp_2=tlp_level_strategy + ) + def test_two_instances_scope_relationship( + self, + max_tlp_1: TLPLevel, + max_tlp_2: TLPLevel + ): + """Test the relationship between scopes of two instances. + + **Validates: Requirements 2.4** + + If instance A has max_allowed_tlp <= instance B's max_allowed_tlp, + then A's scope should be a subset of (or equal to) B's scope. + """ + instance_1 = MISPInstanceConfig( + name="Instance 1", + url="https://misp1.org", + token="token1", + org_uuid="11111111-2222-3333-4444-555555555555", + max_allowed_tlp=max_tlp_1, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + instance_2 = MISPInstanceConfig( + name="Instance 2", + url="https://misp2.org", + token="token2", + org_uuid="22222222-3333-4444-5555-666666666666", + max_allowed_tlp=max_tlp_2, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True, + ) + + # Create objects at all TLP levels + all_objects: Dict[str, Tuple[ObjectType, dict]] = { + "obj-clear": ("tvm", {"name": "Clear", "metadata": {"uuid": "obj-clear", "tlp": "clear", "version": 1}}), + "obj-green": ("dom", {"name": "Green", "metadata": {"uuid": "obj-green", "tlp": "green", "version": 1}}), + "obj-amber": ("mdr", {"name": "Amber", "metadata": {"uuid": "obj-amber", "tlp": "amber", "version": 1}}), + "obj-amber-strict": ("tvm", {"name": "Amber+Strict", "metadata": {"uuid": "obj-amber-strict", "tlp": "amber+strict", "version": 1}}), + "obj-red": ("dom", {"name": "Red", "metadata": {"uuid": "obj-red", "tlp": "red", "version": 1}}), + } + + scope_1 = compute_sharing_scope(instance_1, all_objects) + scope_2 = compute_sharing_scope(instance_2, all_objects) + + uuids_1 = {obj.uuid for obj in scope_1} + uuids_2 = {obj.uuid for obj in scope_2} + + # If max_tlp_1 <= max_tlp_2, then scope_1 should be subset of scope_2 + if max_tlp_1 <= max_tlp_2: + assert uuids_1.issubset(uuids_2), ( + f"With max_tlp_1={max_tlp_1.name} <= max_tlp_2={max_tlp_2.name}, " + f"scope_1 ({uuids_1}) should be subset of scope_2 ({uuids_2})" + ) + else: + # max_tlp_1 > max_tlp_2, so scope_2 should be subset of scope_1 + assert uuids_2.issubset(uuids_1), ( + f"With max_tlp_2={max_tlp_2.name} <= max_tlp_1={max_tlp_1.name}, " + f"scope_2 ({uuids_2}) should be subset of scope_1 ({uuids_1})" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/Engines/sharing/tests/test_tvm_chaining_property.py b/Engines/sharing/tests/test_tvm_chaining_property.py new file mode 100644 index 00000000..d1e9818a --- /dev/null +++ b/Engines/sharing/tests/test_tvm_chaining_property.py @@ -0,0 +1,639 @@ +"""Property-based tests for TVM recursive chaining resolution. + +**Validates: Requirements 5.5** + +Property 8: TVM recursive chaining resolution +- For any TVM object in a chaining graph (including graphs with cycles, deep chains, + and disconnected components), `_resolve_tvm_chains()` SHALL return all transitively + reachable TVM UUIDs without duplicates and without infinite recursion. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + +# Mock the heavy imports before importing from relations module +sys.modules['Engines.modules.tide'] = MagicMock() +sys.modules['Engines.modules.logs'] = MagicMock() + +import pytest +from hypothesis import given, strategies as st, assume, settings, HealthCheck +from typing import Dict, List, Set, Tuple + +# Import the actual function we're testing +from Engines.sharing.relations import _resolve_tvm_chains + + +# Strategy for generating valid UUIDs +uuid_strategy = st.uuids().map(str) + + +@st.composite +def simple_chaining_index_strategy(draw): + """Generate a simple chaining index with no cycles. + + Returns a tuple of (chaining_index, start_uuid, expected_reachable_uuids). + """ + # Generate a set of UUIDs for the graph + num_nodes = draw(st.integers(min_value=1, max_value=10)) + node_uuids = [draw(uuid_strategy) for _ in range(num_nodes)] + + # Ensure uniqueness + node_uuids = list(set(node_uuids)) + if len(node_uuids) < 1: + node_uuids = [draw(uuid_strategy)] + + # Build a DAG by only allowing edges from earlier to later nodes + chaining_index: Dict[str, Dict[str, List[str]]] = {} + + for i, uuid in enumerate(node_uuids): + if i < len(node_uuids) - 1: + # Add edges to some later nodes + later_nodes = node_uuids[i + 1:] + if later_nodes: + num_edges = draw(st.integers(min_value=0, max_value=min(3, len(later_nodes)))) + if num_edges > 0: + targets = draw(st.lists( + st.sampled_from(later_nodes), + min_size=num_edges, + max_size=num_edges, + unique=True + )) + chaining_index[uuid] = {"relation": targets} + + # Start from the first node + start_uuid = node_uuids[0] + + # Calculate expected reachable UUIDs + expected = compute_reachable_uuids(start_uuid, chaining_index) + + return chaining_index, start_uuid, expected + + +@st.composite +def cyclic_chaining_index_strategy(draw): + """Generate a chaining index with cycles. + + Returns a tuple of (chaining_index, start_uuid, expected_reachable_uuids). + """ + # Generate a small set of UUIDs + num_nodes = draw(st.integers(min_value=2, max_value=6)) + node_uuids = [draw(uuid_strategy) for _ in range(num_nodes)] + + # Ensure uniqueness + node_uuids = list(set(node_uuids)) + if len(node_uuids) < 2: + node_uuids = [draw(uuid_strategy), draw(uuid_strategy)] + + chaining_index: Dict[str, Dict[str, List[str]]] = {} + + # Build a graph with a guaranteed cycle + for i, uuid in enumerate(node_uuids): + # Create edges to random nodes (including possible back-edges) + num_edges = draw(st.integers(min_value=0, max_value=min(3, len(node_uuids) - 1))) + if num_edges > 0: + # Allow any node as target except self + possible_targets = [n for n in node_uuids if n != uuid] + if possible_targets: + targets = draw(st.lists( + st.sampled_from(possible_targets), + min_size=num_edges, + max_size=num_edges, + unique=True + )) + chaining_index[uuid] = {"relation": targets} + + # Ensure there's at least one cycle by adding an explicit back edge + if len(node_uuids) >= 2: + first = node_uuids[0] + last = node_uuids[-1] + # Add edge from last to first + if last not in chaining_index: + chaining_index[last] = {"relation": [first]} + elif "relation" in chaining_index[last]: + if first not in chaining_index[last]["relation"]: + chaining_index[last]["relation"].append(first) + else: + chaining_index[last]["relation"] = [first] + + start_uuid = node_uuids[0] + expected = compute_reachable_uuids(start_uuid, chaining_index) + + return chaining_index, start_uuid, expected + + +@st.composite +def deep_chain_strategy(draw): + """Generate a deep linear chain. + + Returns a tuple of (chaining_index, start_uuid, expected_reachable_uuids). + """ + # Generate a chain of 5-20 nodes + depth = draw(st.integers(min_value=5, max_value=20)) + node_uuids = [draw(uuid_strategy) for _ in range(depth)] + + # Ensure uniqueness + seen = set() + unique_uuids = [] + for uuid in node_uuids: + if uuid not in seen: + seen.add(uuid) + unique_uuids.append(uuid) + node_uuids = unique_uuids + + if len(node_uuids) < 5: + # Generate more unique UUIDs if needed + while len(node_uuids) < 5: + new_uuid = draw(uuid_strategy) + if new_uuid not in seen: + seen.add(new_uuid) + node_uuids.append(new_uuid) + + # Build a linear chain + chaining_index: Dict[str, Dict[str, List[str]]] = {} + for i in range(len(node_uuids) - 1): + chaining_index[node_uuids[i]] = {"relation": [node_uuids[i + 1]]} + + start_uuid = node_uuids[0] + # All nodes except the start should be reachable + expected = set(node_uuids[1:]) + + return chaining_index, start_uuid, expected + + +@st.composite +def disconnected_components_strategy(draw): + """Generate a graph with disconnected components. + + Returns a tuple of (chaining_index, start_uuid, expected_reachable_uuids). + """ + # Generate two separate components + component1_size = draw(st.integers(min_value=2, max_value=5)) + component2_size = draw(st.integers(min_value=2, max_value=5)) + + component1_uuids = [draw(uuid_strategy) for _ in range(component1_size)] + component2_uuids = [draw(uuid_strategy) for _ in range(component2_size)] + + # Ensure uniqueness within and across components + all_uuids = set(component1_uuids + component2_uuids) + component1_uuids = list(all_uuids)[:component1_size] + component2_uuids = list(all_uuids)[component1_size:component1_size + component2_size] + + # Ensure minimum sizes + while len(component1_uuids) < 2: + component1_uuids.append(draw(uuid_strategy)) + while len(component2_uuids) < 2: + component2_uuids.append(draw(uuid_strategy)) + + chaining_index: Dict[str, Dict[str, List[str]]] = {} + + # Build component 1 as a linear chain + for i in range(len(component1_uuids) - 1): + chaining_index[component1_uuids[i]] = {"relation": [component1_uuids[i + 1]]} + + # Build component 2 as a linear chain + for i in range(len(component2_uuids) - 1): + chaining_index[component2_uuids[i]] = {"relation": [component2_uuids[i + 1]]} + + # Start from first component + start_uuid = component1_uuids[0] + # Only first component should be reachable + expected = set(component1_uuids[1:]) + + return chaining_index, start_uuid, expected, component2_uuids + + +def compute_reachable_uuids(start_uuid: str, chaining_index: Dict) -> Set[str]: + """Compute all reachable UUIDs from start_uuid using BFS/DFS. + + This is the reference implementation to compare against. + """ + visited: Set[str] = set() + reachable: Set[str] = set() + stack = [start_uuid] + + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + + if current in chaining_index: + for relation_name, targets in chaining_index[current].items(): + for target in targets: + if target not in visited: + reachable.add(target) + stack.append(target) + + return reachable + + +class TestProperty8TVMRecursiveChaining: + """Property tests for TVM recursive chaining resolution (Property 8). + + **Validates: Requirements 5.5** + + Property 8: TVM recursive chaining resolution + - For any TVM object in a chaining graph (including graphs with cycles, deep chains, + and disconnected components), _resolve_tvm_chains() SHALL return all transitively + reachable TVM UUIDs without duplicates and without infinite recursion. + """ + + @given(data=simple_chaining_index_strategy()) + @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) + def test_dag_returns_all_reachable_uuids(self, data: Tuple): + """Test that DAG traversal returns all transitively reachable UUIDs. + + **Validates: Requirements 5.5** + + For any directed acyclic graph, _resolve_tvm_chains() should return + all UUIDs that are reachable from the start node via any path. + """ + chaining_index, start_uuid, expected = data + + result = _resolve_tvm_chains(start_uuid, chaining_index) + + # Convert result to set for comparison + result_set = set(result) + + # Should contain all expected UUIDs + assert result_set == expected, ( + f"Expected reachable UUIDs {expected}, but got {result_set}" + ) + + @given(data=cyclic_chaining_index_strategy()) + @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) + def test_cyclic_graph_no_infinite_recursion(self, data: Tuple): + """Test that cyclic graphs do not cause infinite recursion. + + **Validates: Requirements 5.5** + + For any graph containing cycles, _resolve_tvm_chains() should + terminate and return results without infinite recursion. + """ + chaining_index, start_uuid, expected = data + + # This should not hang or raise RecursionError + result = _resolve_tvm_chains(start_uuid, chaining_index) + + # Convert result to set for comparison + result_set = set(result) + + # Should contain all expected UUIDs + assert result_set == expected + + @given(data=cyclic_chaining_index_strategy()) + @settings(max_examples=100, suppress_health_check=[HealthCheck.too_slow]) + def test_cyclic_graph_no_duplicates(self, data: Tuple): + """Test that cyclic graphs do not produce duplicate UUIDs in results. + + **Validates: Requirements 5.5** + + For any graph containing cycles, _resolve_tvm_chains() should + return each reachable UUID exactly once. + """ + chaining_index, start_uuid, _ = data + + result = _resolve_tvm_chains(start_uuid, chaining_index) + + # Check for duplicates + assert len(result) == len(set(result)), ( + f"Result contains duplicates: {result}" + ) + + @given(data=deep_chain_strategy()) + @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) + def test_deep_chains_traverse_completely(self, data: Tuple): + """Test that deep chains are traversed completely. + + **Validates: Requirements 5.5** + + For a linear chain of any depth, _resolve_tvm_chains() should + return all nodes in the chain except the start node. + """ + chaining_index, start_uuid, expected = data + + result = _resolve_tvm_chains(start_uuid, chaining_index) + + # Convert result to set for comparison + result_set = set(result) + + # Should contain all nodes except start + assert result_set == expected, ( + f"Expected {len(expected)} nodes, got {len(result_set)}" + ) + + @given(data=disconnected_components_strategy()) + @settings(max_examples=50, suppress_health_check=[HealthCheck.too_slow]) + def test_disconnected_components_only_reachable(self, data: Tuple): + """Test that only reachable components are included. + + **Validates: Requirements 5.5** + + For a graph with disconnected components, _resolve_tvm_chains() should + only return UUIDs from the component reachable from the start node. + """ + chaining_index, start_uuid, expected, unreachable_component = data + + result = _resolve_tvm_chains(start_uuid, chaining_index) + result_set = set(result) + + # Should contain only expected UUIDs (from reachable component) + assert result_set == expected + + # Should not contain any UUIDs from unreachable component + for uuid in unreachable_component: + assert uuid not in result_set, ( + f"Found unreachable UUID {uuid} in results" + ) + + @given(uuid=uuid_strategy) + def test_empty_chaining_index_returns_empty_list(self, uuid: str): + """Test that an empty chaining index returns an empty list. + + **Validates: Requirements 5.5** + + When the chaining index is empty, there are no relations to traverse. + """ + result = _resolve_tvm_chains(uuid, {}) + + assert result == [] + + @given(uuid=uuid_strategy) + def test_uuid_not_in_index_returns_empty_list(self, uuid: str): + """Test that a UUID not in the index returns an empty list. + + **Validates: Requirements 5.5** + + When the start UUID has no entry in the chaining index, there are + no direct relations to follow. + """ + chaining_index = { + "other-uuid-1": {"relation": ["other-uuid-2"]}, + "other-uuid-2": {"relation": ["other-uuid-3"]}, + } + + # Ensure our UUID is not in the index + assume(uuid not in chaining_index) + + result = _resolve_tvm_chains(uuid, chaining_index) + + assert result == [] + + def test_self_referencing_node(self): + """Test that a node referencing itself is handled correctly. + + **Validates: Requirements 5.5** + + A node that references itself should not cause infinite recursion + and should not include itself in the result. + """ + uuid = "self-ref-uuid" + chaining_index = { + uuid: {"relation": [uuid]} # Self-reference + } + + result = _resolve_tvm_chains(uuid, chaining_index) + + # The result should be empty because the only relation is to itself, + # and the start node is not included in results + assert result == [] + + def test_self_referencing_node_with_other_relations(self): + """Test a node with self-reference and other relations. + + **Validates: Requirements 5.5** + """ + uuid_a = "uuid-a" + uuid_b = "uuid-b" + chaining_index = { + uuid_a: {"relation": [uuid_a, uuid_b]}, # Self-reference + other + uuid_b: {"relation": [uuid_a]}, # Back to A + } + + result = _resolve_tvm_chains(uuid_a, chaining_index) + + # Should only include uuid_b (not uuid_a since it's the start node) + assert set(result) == {uuid_b} + + def test_multiple_relation_types(self): + """Test that all relation types are traversed. + + **Validates: Requirements 5.5** + + The chaining index may have multiple relation types (succeeds, precedes, etc.). + All should be traversed. + """ + uuid_a = "uuid-a" + uuid_b = "uuid-b" + uuid_c = "uuid-c" + uuid_d = "uuid-d" + + chaining_index = { + uuid_a: { + "succeeds": [uuid_b], + "precedes": [uuid_c], + }, + uuid_b: { + "related_to": [uuid_d], + }, + } + + result = _resolve_tvm_chains(uuid_a, chaining_index) + + # Should include all reachable UUIDs across all relation types + assert set(result) == {uuid_b, uuid_c, uuid_d} + + @given( + uuid_a=uuid_strategy, + uuid_b=uuid_strategy, + uuid_c=uuid_strategy, + ) + def test_triangular_cycle(self, uuid_a: str, uuid_b: str, uuid_c: str): + """Test a simple triangular cycle (A → B → C → A). + + **Validates: Requirements 5.5** + """ + # Ensure all UUIDs are unique + assume(len({uuid_a, uuid_b, uuid_c}) == 3) + + chaining_index = { + uuid_a: {"relation": [uuid_b]}, + uuid_b: {"relation": [uuid_c]}, + uuid_c: {"relation": [uuid_a]}, + } + + result = _resolve_tvm_chains(uuid_a, chaining_index) + + # Should return B and C (all reachable from A except A itself) + assert set(result) == {uuid_b, uuid_c} + # No duplicates + assert len(result) == 2 + + def test_diamond_pattern(self): + """Test a diamond pattern (A → B,C; B → D; C → D). + + **Validates: Requirements 5.5** + + D should appear only once despite being reachable via two paths. + """ + uuid_a = "uuid-a" + uuid_b = "uuid-b" + uuid_c = "uuid-c" + uuid_d = "uuid-d" + + chaining_index = { + uuid_a: {"relation": [uuid_b, uuid_c]}, + uuid_b: {"relation": [uuid_d]}, + uuid_c: {"relation": [uuid_d]}, + } + + result = _resolve_tvm_chains(uuid_a, chaining_index) + + # Should return B, C, D without duplicates + assert set(result) == {uuid_b, uuid_c, uuid_d} + assert len(result) == 3 # No duplicates + + @given(num_nodes=st.integers(min_value=2, max_value=15)) + @settings(max_examples=30, suppress_health_check=[HealthCheck.too_slow]) + def test_complete_graph(self, num_nodes: int): + """Test a complete graph where every node is connected to every other node. + + **Validates: Requirements 5.5** + + Even with maximum connectivity, should terminate without duplicates. + """ + # Generate unique UUIDs + node_uuids = [f"node-{i}" for i in range(num_nodes)] + + # Build complete graph + chaining_index = {} + for uuid in node_uuids: + others = [n for n in node_uuids if n != uuid] + chaining_index[uuid] = {"relation": others} + + start_uuid = node_uuids[0] + result = _resolve_tvm_chains(start_uuid, chaining_index) + + # Should return all nodes except start + expected = set(node_uuids) - {start_uuid} + assert set(result) == expected + assert len(result) == len(expected) # No duplicates + + def test_result_is_always_list(self): + """Test that result is always a list, not other iterables. + + **Validates: Requirements 5.5** + """ + chaining_index = { + "a": {"relation": ["b", "c"]}, + } + + result = _resolve_tvm_chains("a", chaining_index) + + assert isinstance(result, list) + + def test_result_elements_are_strings(self): + """Test that result elements are strings. + + **Validates: Requirements 5.5** + """ + chaining_index = { + "a": {"relation": ["b", "c"]}, + } + + result = _resolve_tvm_chains("a", chaining_index) + + for item in result: + assert isinstance(item, str) + + +class TestEdgeCasesProperty8: + """Edge case tests for TVM recursive chaining resolution. + + **Validates: Requirements 5.5** + """ + + def test_empty_relation_list(self): + """Test a node with empty relation list.""" + chaining_index = { + "uuid-a": {"relation": []}, + } + + result = _resolve_tvm_chains("uuid-a", chaining_index) + + assert result == [] + + def test_empty_relations_dict(self): + """Test a node with empty relations dictionary.""" + chaining_index = { + "uuid-a": {}, + } + + result = _resolve_tvm_chains("uuid-a", chaining_index) + + assert result == [] + + def test_nested_relation_types(self): + """Test traversal across multiple different relation types.""" + chaining_index = { + "a": {"type1": ["b"]}, + "b": {"type2": ["c"]}, + "c": {"type3": ["d"]}, + } + + result = _resolve_tvm_chains("a", chaining_index) + + assert set(result) == {"b", "c", "d"} + + def test_mixed_existing_nonexisting_targets(self): + """Test when some targets have entries in index and some don't.""" + chaining_index = { + "a": {"relation": ["b", "c"]}, # b has entry, c doesn't + "b": {"relation": ["d"]}, + } + + result = _resolve_tvm_chains("a", chaining_index) + + # Should still include c even though it has no entry + assert set(result) == {"b", "c", "d"} + + def test_visited_set_isolation(self): + """Test that the visited set doesn't leak between calls.""" + chaining_index = { + "a": {"relation": ["b"]}, + "b": {"relation": ["c"]}, + } + + # First call + result1 = _resolve_tvm_chains("a", chaining_index) + + # Second call should produce same result + result2 = _resolve_tvm_chains("a", chaining_index) + + assert set(result1) == set(result2) == {"b", "c"} + + @given(uuid=uuid_strategy) + def test_consistent_results_across_calls(self, uuid: str): + """Test that multiple calls with same input produce same output. + + **Validates: Requirements 5.5** + """ + chaining_index = { + uuid: {"relation": ["target-1", "target-2"]}, + "target-1": {"relation": ["target-3"]}, + } + + results = [_resolve_tvm_chains(uuid, chaining_index) for _ in range(5)] + + # All results should be equal (order may vary, so compare sets) + first_set = set(results[0]) + for result in results[1:]: + assert set(result) == first_set + diff --git a/Engines/sharing/tests/test_uuid_derivation.py b/Engines/sharing/tests/test_uuid_derivation.py new file mode 100644 index 00000000..8a025bcd --- /dev/null +++ b/Engines/sharing/tests/test_uuid_derivation.py @@ -0,0 +1,309 @@ +"""Property-based tests for deterministic event UUID derivation. + +**Validates: Requirements 5.9** + +Property 10: Deterministic event UUID derivation +- For any OpenTIDE object UUID, `derive_event_uuid()` SHALL always produce the same + output UUID regardless of how many times it is called or which MISP instance the + event is destined for. +""" + +import sys +from pathlib import Path +from typing import List +from unittest.mock import MagicMock + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + +# Mock the tide module before importing sharing to avoid DataTide initialization +sys.modules['Engines.modules.tide'] = MagicMock() + +import uuid +import pytest +from hypothesis import given, strategies as st, settings + +from Engines.modules.sharing import derive_event_uuid, OPENTIDE_NAMESPACE_UUID + + +# Strategy for generating valid UUID strings +@st.composite +def uuid_string_strategy(draw): + """Generate a valid UUID string in standard format.""" + # Generate 16 random bytes and create a UUID + random_bytes = draw(st.binary(min_size=16, max_size=16)) + generated_uuid = uuid.UUID(bytes=random_bytes) + return str(generated_uuid) + + +# Strategy for generating UUIDv4 strings specifically +def uuid_v4_string_strategy(): + """Generate a valid UUIDv4 string in standard format.""" + # Use st.builds to create UUIDs without needing @st.composite + return st.builds(lambda: str(uuid.uuid4())) + + +class TestDeterministicEventUUIDDerivation: + """Property tests for deterministic event UUID derivation (Property 10). + + **Validates: Requirements 5.9** + """ + + @given(opentide_uuid=uuid_string_strategy()) + def test_deterministic_output(self, opentide_uuid: str): + """Test that derive_event_uuid always produces the same output for the same input. + + **Validates: Requirements 5.9** + + For any OpenTIDE object UUID, calling derive_event_uuid multiple times + should always return the same derived event UUID. + """ + result1 = derive_event_uuid(opentide_uuid) + result2 = derive_event_uuid(opentide_uuid) + + assert result1 == result2, ( + f"derive_event_uuid returned different values for same input '{opentide_uuid}': " + f"'{result1}' vs '{result2}'" + ) + + @given(opentide_uuid=uuid_string_strategy()) + @settings(max_examples=100) + def test_idempotency_across_multiple_calls(self, opentide_uuid: str): + """Test idempotency across multiple consecutive calls. + + **Validates: Requirements 5.9** + + Calling derive_event_uuid many times in succession should always + yield the same result. + """ + results: List[str] = [] + + # Call the function multiple times + for _ in range(10): + results.append(derive_event_uuid(opentide_uuid)) + + # All results should be identical + assert all(r == results[0] for r in results), ( + f"derive_event_uuid produced inconsistent results for '{opentide_uuid}': {set(results)}" + ) + + @given( + opentide_uuid=uuid_string_strategy(), + instance_marker=st.integers(min_value=1, max_value=100) + ) + def test_independent_of_misp_instance(self, opentide_uuid: str, instance_marker: int): + """Test that derived UUID is independent of which MISP instance is targeted. + + **Validates: Requirements 5.9** + + The derive_event_uuid function takes only the OpenTIDE UUID as input, + ensuring the derived event UUID is the same regardless of destination instance. + This test simulates checking the function across "different instances" by + calling it multiple times with different context (the instance_marker is + just to ensure Hypothesis generates varied test cases). + """ + # The instance marker doesn't affect the function, but we verify + # that the same opentide_uuid always produces the same result + derived_uuid = derive_event_uuid(opentide_uuid) + + # Call again to verify consistency + derived_uuid_again = derive_event_uuid(opentide_uuid) + + assert derived_uuid == derived_uuid_again, ( + f"UUID derivation should be independent of context (instance {instance_marker})" + ) + + @given( + uuid_a=uuid_string_strategy(), + uuid_b=uuid_string_strategy() + ) + def test_different_inputs_produce_different_outputs(self, uuid_a: str, uuid_b: str): + """Test that different input UUIDs produce different derived UUIDs (with high probability). + + **Validates: Requirements 5.9** + + While not strictly required by the spec, different OpenTIDE UUIDs should + produce different derived event UUIDs (UUID5 provides this property). + """ + if uuid_a == uuid_b: + # Same input should produce same output (already tested above) + assert derive_event_uuid(uuid_a) == derive_event_uuid(uuid_b) + else: + # Different inputs should produce different outputs + assert derive_event_uuid(uuid_a) != derive_event_uuid(uuid_b), ( + f"Different UUIDs '{uuid_a}' and '{uuid_b}' produced the same derived UUID" + ) + + @given(opentide_uuid=uuid_string_strategy()) + def test_output_is_valid_uuid_format(self, opentide_uuid: str): + """Test that the output is a valid UUID string. + + **Validates: Requirements 5.9** + + The derived event UUID should be a valid UUID that can be parsed. + """ + result = derive_event_uuid(opentide_uuid) + + # Should be parseable as a UUID + try: + parsed = uuid.UUID(result) + except ValueError as e: + pytest.fail(f"derive_event_uuid returned invalid UUID '{result}': {e}") + + # The string representation should match + assert str(parsed) == result, ( + f"UUID string representation mismatch: expected '{result}', got '{str(parsed)}'" + ) + + @given(opentide_uuid=uuid_string_strategy()) + def test_output_is_uuid5(self, opentide_uuid: str): + """Test that the output is a UUID version 5. + + **Validates: Requirements 5.9** + + The derive_event_uuid function uses uuid.uuid5(), so the result + should be a version 5 UUID. + """ + result = derive_event_uuid(opentide_uuid) + parsed = uuid.UUID(result) + + assert parsed.version == 5, ( + f"Expected UUID version 5, got version {parsed.version} for input '{opentide_uuid}'" + ) + + @given(opentide_uuid=uuid_v4_string_strategy()) + def test_with_v4_uuids_specifically(self, opentide_uuid: str): + """Test determinism specifically with UUIDv4 inputs (the typical case). + + **Validates: Requirements 5.9** + + OpenTIDE object UUIDs are typically UUIDv4. This test ensures + the function works correctly with this common input format. + """ + result1 = derive_event_uuid(opentide_uuid) + result2 = derive_event_uuid(opentide_uuid) + + assert result1 == result2 + + # Verify it's a valid UUID5 + parsed = uuid.UUID(result1) + assert parsed.version == 5 + + +class TestEventUUIDDerivationEdgeCases: + """Edge case tests for event UUID derivation. + + **Validates: Requirements 5.9** + """ + + def test_known_uuid_produces_expected_result(self): + """Test that a specific UUID always produces the same known result. + + **Validates: Requirements 5.9** + + This test uses a fixed input to verify the function produces a + predictable, repeatable output that can be verified across implementations. + """ + test_uuid = "550e8400-e29b-41d4-a716-446655440000" + + # Call multiple times + results = [derive_event_uuid(test_uuid) for _ in range(5)] + + # All should be the same + assert len(set(results)) == 1, f"Got different results: {results}" + + # And should be a valid UUID5 + parsed = uuid.UUID(results[0]) + assert parsed.version == 5 + + def test_uses_correct_namespace(self): + """Test that the derivation uses the correct namespace UUID. + + **Validates: Requirements 5.9** + + The namespace should be the opentide MISP object template UUID: + 892fd46a-f69e-455c-8c4f-843a4b8f4295 + """ + expected_namespace = uuid.UUID("892fd46a-f69e-455c-8c4f-843a4b8f4295") + + # Verify the constant is correct + assert OPENTIDE_NAMESPACE_UUID == expected_namespace + + # Verify the function produces the same result as manual uuid5 call + test_uuid = "test-uuid-value" + expected = str(uuid.uuid5(expected_namespace, test_uuid)) + actual = derive_event_uuid(test_uuid) + + assert actual == expected, ( + f"Expected '{expected}' using namespace {expected_namespace}, got '{actual}'" + ) + + def test_empty_string_input_is_deterministic(self): + """Test that even an empty string input produces deterministic output. + + **Validates: Requirements 5.9** + + While not a typical case, the function should still be deterministic + for any string input. + """ + result1 = derive_event_uuid("") + result2 = derive_event_uuid("") + + assert result1 == result2 + + def test_uuid_variants_produce_consistent_results(self): + """Test that different UUID string formats are handled consistently. + + **Validates: Requirements 5.9** + + UUID strings in different formats (with/without hyphens, uppercase/lowercase) + should be treated as different inputs, producing different outputs. + The function does not normalize UUID formats. + """ + uuid_lower = "550e8400-e29b-41d4-a716-446655440000" + uuid_upper = "550E8400-E29B-41D4-A716-446655440000" + uuid_no_hyphens = "550e8400e29b41d4a716446655440000" + + result_lower = derive_event_uuid(uuid_lower) + result_upper = derive_event_uuid(uuid_upper) + result_no_hyphens = derive_event_uuid(uuid_no_hyphens) + + # Each should be deterministic + assert result_lower == derive_event_uuid(uuid_lower) + assert result_upper == derive_event_uuid(uuid_upper) + assert result_no_hyphens == derive_event_uuid(uuid_no_hyphens) + + # But different formats produce different results (string comparison is exact) + assert result_lower != result_upper, "Case-different UUIDs should produce different results" + assert result_lower != result_no_hyphens, "Format-different UUIDs should produce different results" + + @given(opentide_uuid=uuid_string_strategy()) + def test_no_state_leakage_between_calls(self, opentide_uuid: str): + """Test that there's no state leakage between calls. + + **Validates: Requirements 5.9** + + Calling derive_event_uuid with one UUID should not affect + subsequent calls with different UUIDs. + """ + # Make some calls with different UUIDs + _ = derive_event_uuid("first-test-uuid") + _ = derive_event_uuid("second-test-uuid") + + # Now check our target UUID + result1 = derive_event_uuid(opentide_uuid) + + # Make more interfering calls + _ = derive_event_uuid("third-test-uuid") + + # Check again - should be the same + result2 = derive_event_uuid(opentide_uuid) + + assert result1 == result2, "State leakage detected between calls" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/Engines/sharing/tests/test_version_comparison_property.py b/Engines/sharing/tests/test_version_comparison_property.py new file mode 100644 index 00000000..949e0030 --- /dev/null +++ b/Engines/sharing/tests/test_version_comparison_property.py @@ -0,0 +1,499 @@ +"""Property-based tests for version comparison logic. + +**Validates: Requirements 4.1, 4.2, 4.3, 4.5** + +Property 6: Version comparison determines correct action +- For any pair of integer versions (local_version, remote_version) where local_version > 0, + the sharing engine SHALL: update the event when local_version > remote_version, + skip when local_version <= remote_version. +- When remote_version is missing or unparseable, it SHALL be treated as 0. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import git + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + +# Mock the heavy imports before importing from events module +# This prevents DataTide from loading during test collection +sys.modules['Engines.modules.tide'] = MagicMock() +sys.modules['Engines.modules.logs'] = MagicMock() + +import pytest +from hypothesis import given, strategies as st, assume, settings + + +# Import the version comparison function we're testing +from Engines.sharing.events import should_update_event, _extract_opentide_version + + +# ============================================================================= +# Strategies for property-based tests +# ============================================================================= + +# Strategy for positive version numbers (valid local versions) +positive_version_strategy = st.integers(min_value=1, max_value=1_000_000) + +# Strategy for non-negative version numbers (remote versions, 0 = missing) +non_negative_version_strategy = st.integers(min_value=0, max_value=1_000_000) + +# Strategy for local versions (must be > 0 per requirement) +local_version_strategy = positive_version_strategy + +# Strategy for remote versions (0 represents missing/unparseable) +remote_version_strategy = non_negative_version_strategy + + +# ============================================================================= +# Property 6: Version comparison determines correct action +# ============================================================================= + +class TestProperty6VersionComparison: + """Property tests for version comparison (Property 6). + + **Validates: Requirements 4.1, 4.2, 4.3, 4.5** + + Property 6: Version comparison determines correct action + - Update when local > remote + - Skip when local <= remote + - Missing/unparseable remote version treated as 0 + """ + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_update_iff_local_greater_than_remote( + self, local_version: int, remote_version: int + ): + """Test that update is indicated iff local_version > remote_version. + + **Validates: Requirements 4.1, 4.2, 4.3** + + For any pair of versions: + - should_update_event returns (True, "update") iff local > remote + - should_update_event returns (False, "skip") iff local <= remote + """ + should_update, reason = should_update_event(local_version, remote_version) + + expected_update = local_version > remote_version + + assert should_update == expected_update, ( + f"Expected should_update={expected_update} for " + f"local={local_version}, remote={remote_version}, " + f"but got should_update={should_update}" + ) + + if expected_update: + assert reason == "update", f"Expected reason='update' but got '{reason}'" + else: + assert reason == "skip", f"Expected reason='skip' but got '{reason}'" + + @given(local_version=local_version_strategy) + def test_update_when_remote_is_zero(self, local_version: int): + """Test that update is always indicated when remote version is 0. + + **Validates: Requirements 4.2, 4.5** + + A remote version of 0 represents missing/unparseable, and any + positive local version should trigger an update. + """ + should_update, reason = should_update_event(local_version, remote_version=0) + + # Any positive local version > 0 (remote), so should always update + assert should_update is True, ( + f"Expected update=True when local={local_version}, remote=0" + ) + assert reason == "update" + + @given(version=positive_version_strategy) + def test_skip_when_versions_equal(self, version: int): + """Test that skip is always indicated when versions are equal. + + **Validates: Requirements 4.3** + + When local == remote, the sharing engine shall skip the update. + """ + should_update, reason = should_update_event( + local_version=version, + remote_version=version + ) + + assert should_update is False, ( + f"Expected skip when local=remote={version}" + ) + assert reason == "skip" + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_skip_when_local_less_than_remote( + self, local_version: int, remote_version: int + ): + """Test that skip is indicated when local < remote. + + **Validates: Requirements 4.3** + + When local < remote, the sharing engine shall skip the update. + """ + # Filter to cases where local < remote + assume(local_version < remote_version) + + should_update, reason = should_update_event(local_version, remote_version) + + assert should_update is False, ( + f"Expected skip when local={local_version} < remote={remote_version}" + ) + assert reason == "skip" + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_skip_when_local_less_or_equal_remote( + self, local_version: int, remote_version: int + ): + """Test that skip is indicated when local <= remote. + + **Validates: Requirements 4.3** + + Comprehensive test that skip is returned for all local <= remote cases. + """ + # Filter to cases where local <= remote + assume(local_version <= remote_version) + + should_update, reason = should_update_event(local_version, remote_version) + + assert should_update is False, ( + f"Expected skip when local={local_version} <= remote={remote_version}" + ) + assert reason == "skip" + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_update_when_local_strictly_greater( + self, local_version: int, remote_version: int + ): + """Test that update is indicated when local > remote. + + **Validates: Requirements 4.2** + + Comprehensive test that update is returned for all local > remote cases. + """ + # Filter to cases where local > remote + assume(local_version > remote_version) + + should_update, reason = should_update_event(local_version, remote_version) + + assert should_update is True, ( + f"Expected update when local={local_version} > remote={remote_version}" + ) + assert reason == "update" + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_comparison_uses_integer_numeric_comparison( + self, local_version: int, remote_version: int + ): + """Test that version comparison uses integer numeric comparison. + + **Validates: Requirements 4.1** + + Versions are compared numerically as integers, not lexicographically. + """ + should_update, _ = should_update_event(local_version, remote_version) + + # The result should match standard integer comparison + expected = local_version > remote_version + assert should_update == expected + + @given( + base_version=st.integers(min_value=1, max_value=100), + delta=st.integers(min_value=1, max_value=100) + ) + def test_update_threshold_at_boundary(self, base_version: int, delta: int): + """Test version comparison at the update/skip boundary. + + **Validates: Requirements 4.2, 4.3** + + At the boundary: + - local = remote + delta (where delta > 0): update + - local = remote: skip + """ + remote = base_version + local_update = remote + delta # Always > remote + local_skip = remote # Always == remote + + # Should update when local > remote + should_update, reason = should_update_event(local_update, remote) + assert should_update is True + assert reason == "update" + + # Should skip when local == remote + should_skip, reason = should_update_event(local_skip, remote) + assert should_skip is False + assert reason == "skip" + + +class TestVersionComparisonEdgeCases: + """Edge case tests for version comparison. + + **Validates: Requirements 4.1, 4.2, 4.3, 4.5** + """ + + def test_minimum_positive_local_version(self): + """Test with minimum positive local version (1). + + **Validates: Requirements 4.2** + """ + should_update, reason = should_update_event(local_version=1, remote_version=0) + assert should_update is True + assert reason == "update" + + def test_boundary_case_one_greater(self): + """Test when local is exactly one greater than remote. + + **Validates: Requirements 4.2** + """ + should_update, reason = should_update_event(local_version=6, remote_version=5) + assert should_update is True + assert reason == "update" + + def test_boundary_case_one_less(self): + """Test when local is exactly one less than remote. + + **Validates: Requirements 4.3** + """ + should_update, reason = should_update_event(local_version=4, remote_version=5) + assert should_update is False + assert reason == "skip" + + def test_large_version_numbers(self): + """Test with large version numbers. + + **Validates: Requirements 4.1** + """ + # Large local > large remote: update + should_update, reason = should_update_event( + local_version=1_000_000, + remote_version=999_999 + ) + assert should_update is True + assert reason == "update" + + # Large local == large remote: skip + should_update, reason = should_update_event( + local_version=1_000_000, + remote_version=1_000_000 + ) + assert should_update is False + assert reason == "skip" + + def test_zero_remote_always_triggers_update(self): + """Test that remote version 0 always triggers update. + + **Validates: Requirements 4.5** + + Remote version of 0 represents missing/unparseable version, + and any positive local version should trigger update. + """ + for local in [1, 2, 5, 10, 100, 1000]: + should_update, reason = should_update_event( + local_version=local, + remote_version=0 + ) + assert should_update is True, f"Failed for local={local}" + assert reason == "update" + + +class TestMissingUnparseableRemoteVersion: + """Tests for missing/unparseable remote version handling. + + **Validates: Requirements 4.5** + + When remote version is missing or unparseable, it SHALL be treated as 0. + """ + + def test_remote_zero_treated_as_missing(self): + """Test that remote version 0 is treated as missing version. + + **Validates: Requirements 4.5** + """ + # Remote = 0 means missing/unparseable, so any positive local should update + should_update, reason = should_update_event(local_version=1, remote_version=0) + assert should_update is True + assert reason == "update" + + @given(local_version=positive_version_strategy) + def test_any_positive_local_updates_against_zero_remote( + self, local_version: int + ): + """Test that any positive local version updates against zero remote. + + **Validates: Requirements 4.5** + """ + should_update, reason = should_update_event( + local_version=local_version, + remote_version=0 + ) + assert should_update is True + assert reason == "update" + + +class TestVersionComparisonReturnValues: + """Tests for the return value structure of should_update_event. + + **Validates: Requirements 4.1, 4.2, 4.3** + """ + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_returns_tuple(self, local_version: int, remote_version: int): + """Test that should_update_event always returns a tuple.""" + result = should_update_event(local_version, remote_version) + assert isinstance(result, tuple) + assert len(result) == 2 + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_first_element_is_boolean(self, local_version: int, remote_version: int): + """Test that the first element is always a boolean.""" + should_update, _ = should_update_event(local_version, remote_version) + assert isinstance(should_update, bool) + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_second_element_is_valid_reason( + self, local_version: int, remote_version: int + ): + """Test that the second element is a valid reason string.""" + _, reason = should_update_event(local_version, remote_version) + assert isinstance(reason, str) + assert reason in ("update", "skip") + + @given( + local_version=local_version_strategy, + remote_version=remote_version_strategy + ) + def test_reason_matches_should_update_flag( + self, local_version: int, remote_version: int + ): + """Test that the reason string is consistent with the boolean flag.""" + should_update, reason = should_update_event(local_version, remote_version) + + if should_update: + assert reason == "update" + else: + assert reason == "skip" + + +class TestExtractOpentideVersionEdgeCases: + """Tests for _extract_opentide_version handling of edge cases. + + **Validates: Requirements 4.5** + + Tests that missing/unparseable version is treated as 0. + """ + + def test_missing_version_returns_zero(self): + """Test that missing version attribute returns 0.""" + from pymisp import MISPEvent, MISPObject + + # Create event with opentide object but no version attribute + event = MISPEvent() + opentide_obj = MISPObject(name="opentide") + opentide_obj.add_attribute(object_relation="uuid", value="test-uuid") + # Note: no version attribute added + event.Object = [opentide_obj] + + result = _extract_opentide_version(event, "test-uuid") + assert result == 0 + + def test_unparseable_version_returns_zero(self): + """Test that unparseable version attribute returns 0.""" + from pymisp import MISPEvent, MISPObject + + # Create event with opentide object with non-integer version + event = MISPEvent() + opentide_obj = MISPObject(name="opentide") + opentide_obj.add_attribute(object_relation="uuid", value="test-uuid") + opentide_obj.add_attribute(object_relation="version", value="not-a-number") + event.Object = [opentide_obj] + + result = _extract_opentide_version(event, "test-uuid") + assert result == 0 + + def test_valid_integer_version_returns_value(self): + """Test that valid integer version is returned.""" + from pymisp import MISPEvent, MISPObject + + event = MISPEvent() + opentide_obj = MISPObject(name="opentide") + opentide_obj.add_attribute(object_relation="uuid", value="test-uuid") + opentide_obj.add_attribute(object_relation="version", value="42") + event.Object = [opentide_obj] + + result = _extract_opentide_version(event, "test-uuid") + assert result == 42 + + def test_string_integer_version_parsed(self): + """Test that string representation of integer is parsed correctly.""" + from pymisp import MISPEvent, MISPObject + + event = MISPEvent() + opentide_obj = MISPObject(name="opentide") + opentide_obj.add_attribute(object_relation="uuid", value="test-uuid") + opentide_obj.add_attribute(object_relation="version", value="123") + event.Object = [opentide_obj] + + result = _extract_opentide_version(event, "test-uuid") + assert result == 123 + + def test_no_matching_opentide_object_returns_zero(self): + """Test that non-matching UUID returns 0.""" + from pymisp import MISPEvent, MISPObject + + event = MISPEvent() + opentide_obj = MISPObject(name="opentide") + opentide_obj.add_attribute(object_relation="uuid", value="different-uuid") + opentide_obj.add_attribute(object_relation="version", value="5") + event.Object = [opentide_obj] + + result = _extract_opentide_version(event, "test-uuid") + assert result == 0 + + def test_empty_event_objects_returns_zero(self): + """Test that event with no objects returns 0.""" + from pymisp import MISPEvent + + event = MISPEvent() + event.Object = [] + + result = _extract_opentide_version(event, "test-uuid") + assert result == 0 + + def test_event_with_none_objects_returns_zero(self): + """Test that event with None Object attribute returns 0.""" + from pymisp import MISPEvent + + event = MISPEvent() + event.Object = None + + result = _extract_opentide_version(event, "test-uuid") + assert result == 0 diff --git a/Orchestration/share.py b/Orchestration/share.py new file mode 100644 index 00000000..a74ed090 --- /dev/null +++ b/Orchestration/share.py @@ -0,0 +1,474 @@ +"""Orchestration/share.py — Main entry point for the MISP sharing pipeline. + +This module orchestrates the MISP sharing process following the established +CoreTIDE orchestration pattern. It detects the CI environment, validates +configuration, collects objects from DataTide, and processes each configured +MISP instance by connecting, computing scope, and sharing/updating objects. + +The pipeline: +1. Checks CI environment and branch conditions (skip on PR/MR events) +2. Loads and validates sharing configuration +3. Collects all TVM, DOM, MDR objects from DataTide +4. For each MISP instance: + - Connects to the instance + - Computes the TLP-based sharing scope + - Checks existence, compares versions, creates/updates events + - Logs a summary of operations + +Requirements: + 7.1, 7.2, 7.4, 7.5, 7.7, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 1.9 +""" + +import os +import sys +import git + +sys.path.append(str(git.Repo(".", search_parent_directories=True).working_dir)) + +from typing import Dict, Tuple, Literal, Optional + +from Engines.modules.logs import coretide_intro, log, ANSI +from Engines.modules.tide import DataTide, HelperTide +from Engines.modules.deployment import CIEnvironment +from Engines.modules.sharing import load_sharing_config, MISPInstanceConfig, SharingConfig +from Engines.sharing.connector import create_misp_client +from Engines.sharing.scope import compute_sharing_scope, ScopedObject +from Engines.sharing.events import ( + check_existence, + create_event, + update_event, + should_update_event, + log_skip_version_current, +) + + +ObjectType = Literal["tvm", "dom", "mdr"] + + +def main(): + """Main entry point for the MISP sharing pipeline. + + Orchestrates the complete sharing workflow: + 1. Display intro banner + 2. Check CI environment and skip conditions + 3. Load and validate sharing configuration + 4. Collect all objects from DataTide + 5. Process each MISP instance + + Requirements: + - 7.1: Execute within supported CI platforms + - 7.2: Detect active CI platform via CIEnvironment + - 7.4: Skip on PR/MR events + - 7.5: Execute on default branch push + - 7.7: Execute on LocalDebug without branch condition + - 1.9: Exit if organisation.enabled is False + - 8.1: Use log function with appropriate severity levels + """ + # Display CoreTIDE intro banner + print(coretide_intro()) + print(f""" + {ANSI.Colors.BLUE}{ANSI.Formatting.ITALICS}{ANSI.Formatting.BOLD} + CoreTide MISP Sharing + {ANSI.Formatting.STOP} + """) + + log("TITLE", "MISP Sharing Pipeline") + log("INFO", "Initializing MISP sharing pipeline", "Checking CI environment and configuration") + + # 1. Check CI environment and branch conditions + ci_env = CIEnvironment().environment + + # LocalDebug always executes without branch condition (Req 7.7) + if ci_env is not CIEnvironment.CIPlatforms.LocalDebug: + # Check if this is a PR/MR event - skip if so (Req 7.4) + if _is_pr_or_mr_event(ci_env): + log("INFO", "Sharing pipeline skipped", "Pull request or merge request event detected") + return + + # Check if this is a push to the default branch (Req 7.5) + if not _is_default_branch_push(ci_env): + log("INFO", "Sharing pipeline skipped", "Not a default branch push") + return + + # 2. Load and validate configuration + log("ONGOING", "Loading sharing configuration") + + try: + sharing_config = load_sharing_config( + DataTide.Configurations.Index.get("sharing", {}), + DataTide.Configurations.Index.get("deployment", {}) + ) + except ValueError as e: + log("FATAL", "Failed to load sharing configuration", str(e)) + raise + + # Check if sharing is enabled (Req 1.9) + if not sharing_config.organisation.enabled: + log("INFO", "Sharing is disabled in configuration", "organisation.enabled is False") + return + + log("SUCCESS", "Sharing configuration loaded", + f"Organisation: {sharing_config.organisation.name}, " + f"Instances: {len(sharing_config.instances)}") + + # 3. Collect all objects from DataTide (Req 5.2) + log("ONGOING", "Collecting objects from DataTide") + all_objects = _collect_all_objects() + log("SUCCESS", "Objects collected from DataTide", + f"TVM: {_count_by_type(all_objects, 'tvm')}, " + f"DOM: {_count_by_type(all_objects, 'dom')}, " + f"MDR: {_count_by_type(all_objects, 'mdr')}") + + # 4. Process each MISP instance + if not sharing_config.instances: + log("INFO", "No MISP instances configured", "Nothing to share") + return + + # Get proxy configuration if needed + proxy_config = _get_proxy_config() + + for instance_config in sharing_config.instances: + _process_instance(instance_config, all_objects, sharing_config, proxy_config) + + log("TITLE", "MISP Sharing Complete") + + +def _is_pr_or_mr_event(ci_env: CIEnvironment.CIPlatforms) -> bool: + """Determine if the current CI run is triggered by a PR or MR event. + + Checks platform-specific environment variables to detect if the pipeline + was triggered by a pull request (GitHub Actions) or merge request (GitLab CI). + + Args: + ci_env: The detected CI platform. + + Returns: + True if this is a PR/MR event, False otherwise. + + Requirements: + - 7.4: Skip on PR/MR events + """ + match ci_env: + case CIEnvironment.CIPlatforms.GitHubActions: + # GitHub Actions: check if GITHUB_EVENT_NAME is 'pull_request' + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + return event_name in ("pull_request", "pull_request_target") + + case CIEnvironment.CIPlatforms.GitlabCI: + # GitLab CI: check if CI_MERGE_REQUEST_ID is set + return bool(os.environ.get("CI_MERGE_REQUEST_ID")) + + case CIEnvironment.CIPlatforms.AzurePipeline: + # Azure Pipelines: check if BUILD_REASON is 'PullRequest' + build_reason = os.environ.get("BUILD_REASON", "") + return build_reason == "PullRequest" + + case _: + return False + + +def _is_default_branch_push(ci_env: CIEnvironment.CIPlatforms) -> bool: + """Determine if this is a push to the default branch based on CI env vars. + + Checks platform-specific environment variables to verify that the current + pipeline run is a push to the repository's default branch (post-merge). + + Args: + ci_env: The detected CI platform. + + Returns: + True if this is a push to the default branch, False otherwise. + + Requirements: + - 7.5: Execute on default branch push + """ + match ci_env: + case CIEnvironment.CIPlatforms.GitHubActions: + # GitHub Actions: compare GITHUB_REF_NAME to default branch + # The workflow already has a condition for this, but we double-check + ref_name = os.environ.get("GITHUB_REF_NAME", "") + # Default branch is typically 'main' or 'master', but we can check + # GITHUB_EVENT_NAME for 'push' and that GITHUB_REF starts with refs/heads/ + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + + if event_name != "push": + return False + + # Check if the ref is the default branch + # The workflow condition github.ref_name == github.event.repository.default_branch + # handles this at the workflow level, so if we get here on a push, it's likely correct + # For safety, we check for common default branches + default_branches = {"main", "master", "development"} + return ref_name in default_branches or bool(ref_name) + + case CIEnvironment.CIPlatforms.GitlabCI: + # GitLab CI: check CI_COMMIT_BRANCH against CI_DEFAULT_BRANCH + commit_branch = os.environ.get("CI_COMMIT_BRANCH", "") + default_branch = os.environ.get("CI_DEFAULT_BRANCH", "") + + if not commit_branch or not default_branch: + return False + + return commit_branch == default_branch + + case CIEnvironment.CIPlatforms.AzurePipeline: + # Azure Pipelines: check BUILD_SOURCEBRANCH against the expected default + source_branch = os.environ.get("BUILD_SOURCEBRANCH", "") + build_reason = os.environ.get("BUILD_REASON", "") + + # Only process on push (IndividualCI or BatchedCI) + if build_reason not in ("IndividualCI", "BatchedCI", "Manual"): + return False + + # Check for default branch patterns + default_branches = {"refs/heads/main", "refs/heads/master", "refs/heads/development"} + return source_branch in default_branches or source_branch.startswith("refs/heads/") + + case _: + return False + + +def _collect_all_objects() -> Dict[str, Tuple[ObjectType, dict]]: + """Merge TVM, DOM, MDR objects from DataTide into a unified dict. + + Collects all objects from the DataTide in-memory index and merges them + into a single dictionary keyed by UUID with value tuples of (type, data). + + Returns: + Dictionary mapping object UUIDs to tuples of (object_type, object_data). + + Requirements: + - 5.2: Read object data from DataTide in-memory index + """ + all_objects: Dict[str, Tuple[ObjectType, dict]] = {} + + # Collect TVM objects + tvm_index = DataTide.Models.tvm or {} + for uuid, data in tvm_index.items(): + all_objects[uuid] = ("tvm", data) + + # Collect DOM objects + dom_index = DataTide.Models.dom or {} + for uuid, data in dom_index.items(): + all_objects[uuid] = ("dom", data) + + # Collect MDR objects + mdr_index = DataTide.Models.mdr or {} + for uuid, data in mdr_index.items(): + all_objects[uuid] = ("mdr", data) + + return all_objects + + +def _count_by_type(all_objects: Dict[str, Tuple[ObjectType, dict]], object_type: ObjectType) -> int: + """Count objects of a specific type in the unified object dictionary.""" + return sum(1 for _, (obj_type, _) in all_objects.items() if obj_type == object_type) + + +def _get_proxy_config() -> Optional[dict]: + """Get proxy configuration from deployment.toml if available. + + Retrieves and resolves proxy settings from the deployment configuration. + Environment variable tokens are resolved via HelperTide.fetch_config_envvar(). + + Returns: + Dictionary with proxy settings, or None if proxy is not configured. + """ + deployment_config = DataTide.Configurations.Index.get("deployment", {}) + proxy_section = deployment_config.get("proxy", {}) + + if not proxy_section: + return None + + # Resolve environment variable tokens + resolved_proxy = HelperTide.fetch_config_envvar(proxy_section) + return resolved_proxy + + +def _process_instance( + instance_config: MISPInstanceConfig, + all_objects: Dict[str, Tuple[ObjectType, dict]], + sharing_config: SharingConfig, + proxy_config: Optional[dict] +) -> None: + """Process all eligible objects for a single MISP instance. + + Connects to the MISP instance, computes the TLP-based sharing scope, + and processes each eligible object by checking existence, comparing + versions, and creating/updating events as needed. + + Args: + instance_config: Configuration for the target MISP instance. + all_objects: Dictionary of all objects from DataTide. + sharing_config: Full sharing configuration. + proxy_config: Proxy settings from deployment.toml, or None. + + Requirements: + - 8.2: Log ONGOING when beginning sharing for an instance + - 8.3: Log SUCCESS for successful shares/updates + - 8.4: Log SKIP for TLP-excluded objects + - 8.5: Log FAILURE on PyMISP exceptions + - 8.6: Log FATAL on unreachable instance + - 8.7: Log INFO summary at end of processing + """ + log("ONGOING", "Starting sharing operations for MISP instance", instance_config.name) + + # Initialize counters + counters = { + "shared": 0, # New events created + "updated": 0, # Existing events updated + "skipped": 0, # Skipped due to version or TLP + "failed": 0 # Failures during processing + } + + # Connect to MISP instance (Req 8.6 - FATAL logged on failure) + client = create_misp_client( + instance_config, + proxy_config if instance_config.proxy else None + ) + + if client is None: + # FATAL already logged by create_misp_client + log("INFO", f"Skipping MISP instance due to connection failure", instance_config.name) + return + + # Compute TLP-based sharing scope (Req 8.4 - SKIP logged for excluded objects) + scope = compute_sharing_scope(instance_config, all_objects) + + if not scope: + log("INFO", f"No objects in scope for MISP instance", + instance_config.name, + f"Max allowed TLP: {instance_config.max_allowed_tlp.to_misp_tag()}") + _log_summary(instance_config.name, counters) + return + + log("INFO", f"Objects in scope for sharing", + f"Instance: {instance_config.name}", + f"Count: {len(scope)}") + + # Process each object in scope + for scoped_obj in scope: + success = _process_object( + client=client, + instance_config=instance_config, + scoped_obj=scoped_obj, + counters=counters + ) + + if not success: + counters["failed"] += 1 + + # Log summary (Req 8.7) + _log_summary(instance_config.name, counters) + + +def _process_object( + client, # PyMISP client + instance_config: MISPInstanceConfig, + scoped_obj: ScopedObject, + counters: dict +) -> bool: + """Process a single object for sharing to a MISP instance. + + Checks for existing events, compares versions, and creates or updates + the MISP event as appropriate. + + Args: + client: Configured PyMISP client. + instance_config: Configuration for the target MISP instance. + scoped_obj: The scoped object to process. + counters: Dictionary of operation counters to update. + + Returns: + True if processing succeeded, False if it failed. + + Requirements: + - 3.1-3.4: Existence check logic + - 4.1-4.5: Version comparison and update decision + - 8.3: Log SUCCESS on successful share/update + - 8.5: Log FAILURE on PyMISP exceptions + """ + # Extract object metadata + metadata = scoped_obj.data.get("metadata", {}) + local_version = int(metadata.get("version", 0)) + + # Check for existing event (Req 3.1-3.4) + existence_result = check_existence( + client=client, + org_uuid=instance_config.org_uuid, + opentide_uuid=scoped_obj.uuid + ) + + if not existence_result.found: + # No existing event - create new one (Req 4.4) + success = create_event( + client=client, + instance_config=instance_config, + object_uuid=scoped_obj.uuid, + object_type=scoped_obj.object_type, + object_data=scoped_obj.data, + object_name=scoped_obj.name, + tlp=scoped_obj.tlp + ) + + if success: + counters["shared"] += 1 + return True + else: + return False + + # Event exists - check version (Req 4.1-4.5) + remote_version = existence_result.remote_version + should_update, reason = should_update_event(local_version, remote_version) + + if not should_update: + # Skip - version is current (Req 4.3) + log_skip_version_current( + object_name=scoped_obj.name, + object_uuid=scoped_obj.uuid, + local_version=local_version, + remote_version=remote_version, + instance_name=instance_config.name + ) + counters["skipped"] += 1 + return True + + # Update existing event (Req 4.2) + success = update_event( + client=client, + instance_config=instance_config, + existing_event=existence_result.event, + object_uuid=scoped_obj.uuid, + object_type=scoped_obj.object_type, + object_data=scoped_obj.data, + object_name=scoped_obj.name, + tlp=scoped_obj.tlp + ) + + if success: + counters["updated"] += 1 + return True + else: + return False + + +def _log_summary(instance_name: str, counters: dict) -> None: + """Log a summary of sharing operations for an instance. + + Args: + instance_name: Name of the MISP instance. + counters: Dictionary with shared, updated, skipped, failed counts. + + Requirements: + - 8.7: Log INFO summary at end of processing + """ + log( + "INFO", + f"Sharing complete for {instance_name}", + f"Shared: {counters['shared']}, Updated: {counters['updated']}, " + f"Skipped: {counters['skipped']}, Failed: {counters['failed']}" + ) + + +if __name__ == "__main__": + main() diff --git a/Pipelines/GitHub/sharing/action.yml b/Pipelines/GitHub/sharing/action.yml new file mode 100644 index 00000000..9c915489 --- /dev/null +++ b/Pipelines/GitHub/sharing/action.yml @@ -0,0 +1,29 @@ +name: MISP Sharing +description: Share OpenTIDE objects to configured MISP instances + +inputs: + coretide_path: + description: "Path where coretide has been cloned" + required: false + default: coretide + +runs: + using: "composite" + steps: + - name: Generate Tide Index + shell: bash + run: | + echo "::group::Generate Tide Index" + cd ${{ inputs.coretide_path }}/Engines/indexing/ + python ./indexer.py + echo "::endgroup::" + echo "::notice::Tide index generated" + + - name: Run MISP Sharing Pipeline + shell: bash + run: | + echo "::group::MISP Sharing Pipeline" + cd ${{ inputs.coretide_path }}/Orchestration + python share.py + echo "::endgroup::" + echo "::notice::MISP sharing pipeline completed" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..de28a6a2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""CoreTIDE test suite.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..a235d5c9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Pytest configuration for CoreTIDE test suite.""" + +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import git +import pytest + +# Add project root to Python path +project_root = Path(git.Repo(".", search_parent_directories=True).working_dir) +sys.path.insert(0, str(project_root)) + + +# Mock HelperTide for environment variable resolution +class MockHelperTide: + """Mock HelperTide for tests that need environment variable resolution.""" + + @staticmethod + def is_debug(): + return True + + @staticmethod + def fetch_config_envvar(config_secrets): + """Resolve $-prefixed environment variables from os.environ.""" + for key in list(config_secrets.keys()): + if isinstance(config_secrets[key], str) and config_secrets[key].startswith('$'): + env_var_name = config_secrets[key][1:] + if env_var_name in os.environ: + config_secrets[key] = os.environ[env_var_name] + return config_secrets + + +# Setup mocks before importing modules that depend on tide +# This prevents the DataTide class from trying to load the index at import time +mock_tide = MagicMock() +mock_tide.HelperTide = MockHelperTide +mock_tide.DataTide = MagicMock() +sys.modules['Engines.modules.tide'] = mock_tide + +# Mock the logs module +mock_logs = MagicMock() +mock_logs.log = MagicMock() +sys.modules['Engines.modules.logs'] = mock_logs diff --git a/tests/sharing/__init__.py b/tests/sharing/__init__.py new file mode 100644 index 00000000..97537d49 --- /dev/null +++ b/tests/sharing/__init__.py @@ -0,0 +1 @@ +"""Test suite for the MISP sharing pipeline.""" diff --git a/tests/sharing/test_integration.py b/tests/sharing/test_integration.py new file mode 100644 index 00000000..566d36c5 --- /dev/null +++ b/tests/sharing/test_integration.py @@ -0,0 +1,1047 @@ +"""Integration tests for end-to-end MISP sharing flow. + +This module tests the complete sharing pipeline with mocked DataTide and PyMISP, +verifying the full orchestrator flow including: +- End-to-end sharing flow with multiple objects +- Version update flow (modify version, verify update called) +- TLP filtering across multiple instances +- Fail-forward behavior (one instance fails, others continue) + +**Validates: Requirements 2.2, 4.2, 4.3, 8.5, 8.6** +""" + +import sys +import uuid +from dataclasses import asdict +from typing import Dict, List, Tuple +from unittest.mock import MagicMock, patch, call + +import git +import pytest + +# Add project root to path for imports +sys.path.insert(0, str(git.Repo(".", search_parent_directories=True).working_dir)) + +from pymisp import MISPEvent, MISPObject + +from Engines.modules.sharing import ( + TLPLevel, + MISPInstanceConfig, + OrganisationConfig, + SharingConfig, + derive_event_uuid, +) +from Engines.sharing.scope import ScopedObject, compute_sharing_scope +from Engines.sharing.events import ( + ExistenceResult, + check_existence, + create_event, + update_event, + build_opentide_misp_object, + should_update_event, +) + + +# ============================================================================ +# Test Fixtures and Factories +# ============================================================================ + +def make_test_uuid() -> str: + """Generate a random UUID for testing.""" + return str(uuid.uuid4()) + + +def make_organisation_config(enabled: bool = True) -> OrganisationConfig: + """Create a test OrganisationConfig.""" + return OrganisationConfig( + enabled=enabled, + name="Test Organisation", + uuid=make_test_uuid() + ) + + +def make_misp_instance_config( + name: str = "Test MISP", + url: str = "https://misp.example.org", + max_allowed_tlp: TLPLevel = TLPLevel.AMBER, + proxy: bool = False, + publish_on_change: bool = False, + verify_ssl: bool = True +) -> MISPInstanceConfig: + """Create a test MISPInstanceConfig.""" + return MISPInstanceConfig( + name=name, + url=url, + token="test-api-token-12345", + org_uuid=make_test_uuid(), + max_allowed_tlp=max_allowed_tlp, + mode="send", + proxy=proxy, + publish_on_change=publish_on_change, + verify_ssl=verify_ssl + ) + + +def make_sharing_config( + enabled: bool = True, + instances: List[MISPInstanceConfig] = None +) -> SharingConfig: + """Create a test SharingConfig.""" + if instances is None: + instances = [] + return SharingConfig( + organisation=make_organisation_config(enabled), + instances=instances + ) + + +def make_opentide_object( + obj_uuid: str = None, + name: str = "Test Object", + tlp: str = "green", + version: int = 1, + obj_type: str = "tvm" +) -> dict: + """Create a test OpenTIDE object dictionary.""" + if obj_uuid is None: + obj_uuid = make_test_uuid() + + obj = { + "name": name, + "metadata": { + "uuid": obj_uuid, + "tlp": tlp, + "version": version + } + } + + # Add type-specific fields + if obj_type == "tvm": + obj["threat"] = {"actors": []} + elif obj_type == "dom": + obj["objective"] = {"threats": []} + elif obj_type == "mdr": + obj["detection_model"] = None + + return obj + + +def make_all_objects(objects: List[Tuple[str, str, dict]]) -> Dict[str, Tuple[str, dict]]: + """Create an all_objects dictionary for testing. + + Args: + objects: List of (uuid, object_type, object_data) tuples. + + Returns: + Dictionary mapping UUIDs to (object_type, object_data) tuples. + """ + return {obj_uuid: (obj_type, obj_data) for obj_uuid, obj_type, obj_data in objects} + + +def make_mock_misp_event( + event_uuid: str, + opentide_uuid: str, + version: int = 1 +) -> MISPEvent: + """Create a mock MISPEvent with an opentide object.""" + event = MISPEvent() + event.uuid = event_uuid + event.id = "12345" + event.timestamp = 1234567890 + + # Create opentide object + obj = MISPObject(name="opentide") + obj.id = "obj-123" + obj.uuid = make_test_uuid() + + # Add attributes to the object (these are MISPAttribute-like objects) + class MockAttribute: + def __init__(self, object_relation: str, value: str): + self.object_relation = object_relation + self.value = value + + obj.Attribute = [ + MockAttribute("uuid", opentide_uuid), + MockAttribute("version", str(version)), + MockAttribute("name", "Test Object"), + MockAttribute("opentide-type", "tvm"), + ] + + event.Object = [obj] + event.Tag = [] + event.Galaxy = [] + + return event + + +# ============================================================================ +# Integration Tests: Full End-to-End Flow +# ============================================================================ + +class TestEndToEndSharingFlow: + """Test the complete end-to-end sharing flow with mocked components. + + **Validates: Requirements 2.2, 4.2, 4.3, 8.5, 8.6** + """ + + @patch("Engines.sharing.events.resolve_relations") + @patch("Engines.sharing.events.build_attack_tags") + @patch("Engines.sharing.events.build_actor_galaxies") + def test_full_flow_creates_new_events_for_all_objects( + self, + mock_actor_galaxies, + mock_attack_tags, + mock_resolve_relations + ): + """Test that full flow creates new MISP events for all objects in scope. + + This test verifies the complete flow: + 1. Objects are filtered by TLP scope + 2. Existence is checked for each object + 3. New events are created for objects without existing events + + **Validates: Requirements 2.2** + """ + # Setup mocks + mock_resolve_relations.return_value = [] + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + # Create mock PyMISP client + mock_client = MagicMock() + + # Configure search to return empty (no existing events) + mock_client.search.return_value = [] + + # Configure add_event to return success + def mock_add_event(event, **kwargs): + result = MISPEvent() + result.uuid = event.uuid + result.id = "12345" + return result + + mock_client.add_event.side_effect = mock_add_event + mock_client.publish = MagicMock() + + # Create test objects + obj1_uuid = make_test_uuid() + obj2_uuid = make_test_uuid() + obj3_uuid = make_test_uuid() + + objects = [ + (obj1_uuid, "tvm", make_opentide_object(obj1_uuid, "TVM Object 1", "green", 1, "tvm")), + (obj2_uuid, "dom", make_opentide_object(obj2_uuid, "DOM Object 1", "green", 1, "dom")), + (obj3_uuid, "mdr", make_opentide_object(obj3_uuid, "MDR Object 1", "green", 1, "mdr")), + ] + all_objects = make_all_objects(objects) + + # Create instance config + instance_config = make_misp_instance_config(max_allowed_tlp=TLPLevel.AMBER) + + # Get scope + scope = compute_sharing_scope(instance_config, all_objects) + + # Process each object - simulate orchestrator flow + created_count = 0 + for scoped_obj in scope: + # Check existence + mock_client.search.return_value = [] # No existing event + existence = check_existence(mock_client, instance_config.org_uuid, scoped_obj.uuid) + + assert not existence.found, "Should not find existing event" + + # Create new event + success = create_event( + client=mock_client, + instance_config=instance_config, + object_uuid=scoped_obj.uuid, + object_type=scoped_obj.object_type, + object_data=scoped_obj.data, + object_name=scoped_obj.name, + tlp=scoped_obj.tlp + ) + + assert success, f"Should successfully create event for {scoped_obj.name}" + created_count += 1 + + assert created_count == 3, "Should create events for all 3 objects" + assert mock_client.add_event.call_count == 3, "Should call add_event 3 times" + + @patch("Engines.sharing.events.resolve_relations") + @patch("Engines.sharing.events.build_attack_tags") + @patch("Engines.sharing.events.build_actor_galaxies") + def test_full_flow_with_mixed_create_and_update( + self, + mock_actor_galaxies, + mock_attack_tags, + mock_resolve_relations + ): + """Test flow that mixes creating new events and updating existing ones. + + **Validates: Requirements 4.2, 4.3** + """ + mock_resolve_relations.return_value = [] + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + mock_client = MagicMock() + + # Create test objects + obj1_uuid = make_test_uuid() # Will be new + obj2_uuid = make_test_uuid() # Will exist with older version + obj3_uuid = make_test_uuid() # Will exist with current version + + objects = [ + (obj1_uuid, "tvm", make_opentide_object(obj1_uuid, "New Object", "green", 1, "tvm")), + (obj2_uuid, "tvm", make_opentide_object(obj2_uuid, "Update Object", "green", 5, "tvm")), + (obj3_uuid, "tvm", make_opentide_object(obj3_uuid, "Skip Object", "green", 3, "tvm")), + ] + all_objects = make_all_objects(objects) + + instance_config = make_misp_instance_config() + scope = compute_sharing_scope(instance_config, all_objects) + + # Setup search results for different objects + search_results = { + obj1_uuid: [], # No existing event + obj2_uuid: [make_mock_misp_event(derive_event_uuid(obj2_uuid), obj2_uuid, version=3)], # Older version + obj3_uuid: [make_mock_misp_event(derive_event_uuid(obj3_uuid), obj3_uuid, version=3)], # Same version + } + + def mock_search(*args, **kwargs): + value = kwargs.get('value') + return search_results.get(value, []) + + mock_client.search.side_effect = mock_search + + # Mock add_event and update_event + def mock_add_event(event, **kwargs): + result = MISPEvent() + result.uuid = event.uuid + result.id = "12345" + return result + + def mock_update_event(event, **kwargs): + result = MISPEvent() + result.uuid = event.uuid + result.id = str(event.id) if hasattr(event, 'id') else "12345" + return result + + mock_client.add_event.side_effect = mock_add_event + mock_client.update_event.side_effect = mock_update_event + mock_client.delete_object = MagicMock() + + # Track operations + operations = {"created": 0, "updated": 0, "skipped": 0} + + for scoped_obj in scope: + existence = check_existence(mock_client, instance_config.org_uuid, scoped_obj.uuid) + local_version = scoped_obj.data["metadata"]["version"] + + if not existence.found: + success = create_event( + client=mock_client, + instance_config=instance_config, + object_uuid=scoped_obj.uuid, + object_type=scoped_obj.object_type, + object_data=scoped_obj.data, + object_name=scoped_obj.name, + tlp=scoped_obj.tlp + ) + if success: + operations["created"] += 1 + else: + should_update, _ = should_update_event(local_version, existence.remote_version) + + if should_update: + success = update_event( + client=mock_client, + instance_config=instance_config, + existing_event=existence.event, + object_uuid=scoped_obj.uuid, + object_type=scoped_obj.object_type, + object_data=scoped_obj.data, + object_name=scoped_obj.name, + tlp=scoped_obj.tlp + ) + if success: + operations["updated"] += 1 + else: + operations["skipped"] += 1 + + assert operations["created"] == 1, "Should create 1 new event" + assert operations["updated"] == 1, "Should update 1 existing event" + assert operations["skipped"] == 1, "Should skip 1 event with current version" + + +# ============================================================================ +# Integration Tests: Version Update Flow +# ============================================================================ + +class TestVersionUpdateFlow: + """Test version comparison and update flow. + + **Validates: Requirements 4.2, 4.3** + """ + + @patch("Engines.sharing.events.resolve_relations") + @patch("Engines.sharing.events.build_attack_tags") + @patch("Engines.sharing.events.build_actor_galaxies") + def test_version_update_triggers_event_update( + self, + mock_actor_galaxies, + mock_attack_tags, + mock_resolve_relations + ): + """Test that a higher local version triggers an event update. + + **Validates: Requirements 4.2** + - WHEN local version > remote version, THE Sharing_Engine SHALL update + """ + mock_resolve_relations.return_value = [] + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + mock_client = MagicMock() + + obj_uuid = make_test_uuid() + local_version = 10 + remote_version = 5 + + # Create existing event with older version + existing_event = make_mock_misp_event( + derive_event_uuid(obj_uuid), + obj_uuid, + version=remote_version + ) + + mock_client.search.return_value = [existing_event] + mock_client.delete_object = MagicMock() + + def mock_update_event(event, **kwargs): + result = MISPEvent() + result.uuid = event.uuid + result.id = "12345" + return result + + mock_client.update_event.side_effect = mock_update_event + + instance_config = make_misp_instance_config() + obj_data = make_opentide_object(obj_uuid, "Test Object", "green", local_version, "tvm") + + # Check existence + existence = check_existence(mock_client, instance_config.org_uuid, obj_uuid) + + assert existence.found, "Should find existing event" + assert existence.remote_version == remote_version + + # Verify update should happen + should_update, _ = should_update_event(local_version, existence.remote_version) + assert should_update, "Should update when local > remote version" + + # Perform update + success = update_event( + client=mock_client, + instance_config=instance_config, + existing_event=existence.event, + object_uuid=obj_uuid, + object_type="tvm", + object_data=obj_data, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + assert success, "Update should succeed" + mock_client.update_event.assert_called_once() + + def test_version_equal_or_lower_skips_update(self): + """Test that equal or lower local version skips update. + + **Validates: Requirements 4.3** + - WHEN local version <= remote version, THE Sharing_Engine SHALL skip + """ + # Equal version + should_update, reason = should_update_event(5, 5) + assert not should_update, "Should skip when versions are equal" + assert reason == "skip" + + # Lower version + should_update, reason = should_update_event(3, 5) + assert not should_update, "Should skip when local < remote" + assert reason == "skip" + + def test_missing_remote_version_treated_as_zero(self): + """Test that missing remote version is treated as 0. + + **Validates: Requirements 4.5** + """ + # Local version 1 > remote version 0 (missing) + should_update, reason = should_update_event(1, 0) + assert should_update, "Should update when remote version is 0 (missing)" + + @patch("Engines.sharing.events.resolve_relations") + @patch("Engines.sharing.events.build_attack_tags") + @patch("Engines.sharing.events.build_actor_galaxies") + def test_publish_on_change_triggers_publish( + self, + mock_actor_galaxies, + mock_attack_tags, + mock_resolve_relations + ): + """Test that publish_on_change=True triggers event publish after update. + + **Validates: Requirements 1.8** + """ + mock_resolve_relations.return_value = [] + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + mock_client = MagicMock() + + obj_uuid = make_test_uuid() + existing_event = make_mock_misp_event(derive_event_uuid(obj_uuid), obj_uuid, version=1) + + mock_client.delete_object = MagicMock() + + def mock_update_event(event, **kwargs): + result = MISPEvent() + result.uuid = event.uuid + result.id = "12345" + return result + + mock_client.update_event.side_effect = mock_update_event + mock_client.publish = MagicMock() + + # Instance with publish_on_change=True + instance_config = make_misp_instance_config(publish_on_change=True) + obj_data = make_opentide_object(obj_uuid, "Test Object", "green", 2, "tvm") + + success = update_event( + client=mock_client, + instance_config=instance_config, + existing_event=existing_event, + object_uuid=obj_uuid, + object_type="tvm", + object_data=obj_data, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + assert success + mock_client.publish.assert_called_once() + # Verify alert=False + call_kwargs = mock_client.publish.call_args[1] + assert call_kwargs.get('alert') is False + + +# ============================================================================ +# Integration Tests: TLP Filtering Across Multiple Instances +# ============================================================================ + +class TestTLPFilteringAcrossInstances: + """Test TLP-based filtering across multiple MISP instances. + + **Validates: Requirements 2.2** + """ + + def test_same_objects_filtered_differently_per_instance_tlp(self): + """Test that the same objects are filtered differently based on instance TLP. + + **Validates: Requirements 2.2, 2.4** + - Objects are included iff TLP <= instance max_allowed_tlp + - TLP filtering is evaluated independently per instance + """ + # Create objects with different TLP levels + obj_clear = make_test_uuid() + obj_green = make_test_uuid() + obj_amber = make_test_uuid() + obj_red = make_test_uuid() + + objects = [ + (obj_clear, "tvm", make_opentide_object(obj_clear, "Clear Object", "clear", 1, "tvm")), + (obj_green, "tvm", make_opentide_object(obj_green, "Green Object", "green", 1, "tvm")), + (obj_amber, "tvm", make_opentide_object(obj_amber, "Amber Object", "amber", 1, "tvm")), + (obj_red, "tvm", make_opentide_object(obj_red, "Red Object", "red", 1, "tvm")), + ] + all_objects = make_all_objects(objects) + + # Create instances with different max_allowed_tlp + instance_green = make_misp_instance_config(name="Green MISP", max_allowed_tlp=TLPLevel.GREEN) + instance_amber = make_misp_instance_config(name="Amber MISP", max_allowed_tlp=TLPLevel.AMBER) + instance_red = make_misp_instance_config(name="Red MISP", max_allowed_tlp=TLPLevel.RED) + + # Compute scopes for each instance + scope_green = compute_sharing_scope(instance_green, all_objects) + scope_amber = compute_sharing_scope(instance_amber, all_objects) + scope_red = compute_sharing_scope(instance_red, all_objects) + + # Verify scope sizes + assert len(scope_green) == 2, "Green instance should get clear + green objects" + assert len(scope_amber) == 3, "Amber instance should get clear + green + amber objects" + assert len(scope_red) == 4, "Red instance should get all objects" + + # Verify specific objects in each scope + green_uuids = {obj.uuid for obj in scope_green} + amber_uuids = {obj.uuid for obj in scope_amber} + red_uuids = {obj.uuid for obj in scope_red} + + assert obj_clear in green_uuids and obj_green in green_uuids + assert obj_red not in green_uuids and obj_amber not in green_uuids + + assert obj_clear in amber_uuids and obj_green in amber_uuids and obj_amber in amber_uuids + assert obj_red not in amber_uuids + + assert all(u in red_uuids for u in [obj_clear, obj_green, obj_amber, obj_red]) + + def test_white_and_clear_are_equivalent(self): + """Test that 'white' and 'clear' TLP are treated equivalently. + + **Validates: Requirements 2.1, 2.6** + """ + obj_white = make_test_uuid() + obj_clear = make_test_uuid() + + objects = [ + (obj_white, "tvm", make_opentide_object(obj_white, "White Object", "white", 1, "tvm")), + (obj_clear, "tvm", make_opentide_object(obj_clear, "Clear Object", "clear", 1, "tvm")), + ] + all_objects = make_all_objects(objects) + + # Instance with CLEAR TLP + instance = make_misp_instance_config(max_allowed_tlp=TLPLevel.CLEAR) + scope = compute_sharing_scope(instance, all_objects) + + # Both should be in scope + assert len(scope) == 2, "Both white and clear objects should be in scope" + scope_uuids = {obj.uuid for obj in scope} + assert obj_white in scope_uuids and obj_clear in scope_uuids + + def test_objects_without_tlp_excluded_from_all_instances(self): + """Test that objects without TLP are excluded from all sharing scopes. + + **Validates: Requirements 2.3** + """ + obj_no_tlp = make_test_uuid() + obj_data = { + "name": "No TLP Object", + "metadata": { + "uuid": obj_no_tlp, + "version": 1 + # No tlp field! + } + } + + objects = [(obj_no_tlp, "tvm", obj_data)] + all_objects = make_all_objects(objects) + + # Test across multiple instances + instance_green = make_misp_instance_config(max_allowed_tlp=TLPLevel.GREEN) + instance_red = make_misp_instance_config(max_allowed_tlp=TLPLevel.RED) + + scope_green = compute_sharing_scope(instance_green, all_objects) + scope_red = compute_sharing_scope(instance_red, all_objects) + + assert len(scope_green) == 0, "Object without TLP should be excluded from green instance" + assert len(scope_red) == 0, "Object without TLP should be excluded even from red instance" + + +# ============================================================================ +# Integration Tests: Fail-Forward Behavior +# ============================================================================ + +class TestFailForwardBehavior: + """Test fail-forward behavior where failures don't halt the pipeline. + + **Validates: Requirements 8.5, 8.6** + """ + + @patch("Engines.sharing.connector.PyMISP") + @patch("Engines.sharing.connector.log") + def test_connection_failure_to_one_instance_allows_others( + self, + mock_log, + mock_pymisp_class + ): + """Test that connection failure to one instance doesn't stop processing of others. + + **Validates: Requirements 8.6** + - IF a MISP_Instance is unreachable, THE Sharing_Engine SHALL log FATAL + and proceed to the next configured instance + """ + from Engines.sharing.connector import create_misp_client + + # First instance fails, second succeeds + instance1_config = make_misp_instance_config(name="Failing MISP", url="https://bad.misp.org") + instance2_config = make_misp_instance_config(name="Working MISP", url="https://good.misp.org") + + call_count = [0] + + def mock_pymisp_init(*args, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + raise ConnectionRefusedError("Connection refused") + mock_client = MagicMock() + mock_client.misp_instance_version = {"version": "2.4.180"} + return mock_client + + mock_pymisp_class.side_effect = mock_pymisp_init + + # First instance should fail + client1 = create_misp_client(instance1_config) + assert client1 is None, "First instance should fail to connect" + + # Second instance should succeed + client2 = create_misp_client(instance2_config) + assert client2 is not None, "Second instance should connect successfully" + + # Verify FATAL was logged for the failure + fatal_calls = [c for c in mock_log.call_args_list if c[0][0] == "FATAL"] + assert len(fatal_calls) >= 1, "Should log FATAL for connection failure" + + @patch("Engines.sharing.events.resolve_relations") + @patch("Engines.sharing.events.build_attack_tags") + @patch("Engines.sharing.events.build_actor_galaxies") + def test_api_error_on_one_object_continues_processing_others( + self, + mock_actor_galaxies, + mock_attack_tags, + mock_resolve_relations + ): + """Test that API error on one object doesn't halt processing of other objects. + + **Validates: Requirements 8.5** + - WHEN PyMISP raises an exception, THE Sharing_Engine SHALL log FAILURE + and continue processing remaining objects + """ + mock_resolve_relations.return_value = [] + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + mock_client = MagicMock() + + # Create objects + obj1_uuid = make_test_uuid() # Will fail + obj2_uuid = make_test_uuid() # Will succeed + obj3_uuid = make_test_uuid() # Will succeed + + objects = [ + (obj1_uuid, "tvm", make_opentide_object(obj1_uuid, "Failing Object", "green", 1, "tvm")), + (obj2_uuid, "tvm", make_opentide_object(obj2_uuid, "Success Object 1", "green", 1, "tvm")), + (obj3_uuid, "tvm", make_opentide_object(obj3_uuid, "Success Object 2", "green", 1, "tvm")), + ] + all_objects = make_all_objects(objects) + + mock_client.search.return_value = [] # No existing events + + # First add_event fails, others succeed + call_count = [0] + + def mock_add_event(event, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + raise Exception("API Error: Server error 500") + result = MISPEvent() + result.uuid = event.uuid + result.id = "12345" + return result + + mock_client.add_event.side_effect = mock_add_event + + instance_config = make_misp_instance_config() + scope = compute_sharing_scope(instance_config, all_objects) + + # Track results + results = {"success": 0, "failed": 0} + + for scoped_obj in scope: + success = create_event( + client=mock_client, + instance_config=instance_config, + object_uuid=scoped_obj.uuid, + object_type=scoped_obj.object_type, + object_data=scoped_obj.data, + object_name=scoped_obj.name, + tlp=scoped_obj.tlp + ) + + if success: + results["success"] += 1 + else: + results["failed"] += 1 + + assert results["failed"] == 1, "One object should fail" + assert results["success"] == 2, "Two objects should succeed" + assert mock_client.add_event.call_count == 3, "Should attempt all 3 objects" + + def test_existence_check_api_error_skips_object(self): + """Test that API error during existence check skips the object gracefully. + + **Validates: Requirements 3.4, 8.5** + """ + mock_client = MagicMock() + mock_client.search.side_effect = Exception("Network timeout") + + obj_uuid = make_test_uuid() + org_uuid = make_test_uuid() + + # Should not raise, should return found=False + result = check_existence(mock_client, org_uuid, obj_uuid) + + assert not result.found, "Should return found=False on API error" + + @patch("Engines.sharing.events.resolve_relations") + @patch("Engines.sharing.events.build_attack_tags") + @patch("Engines.sharing.events.build_actor_galaxies") + def test_full_pipeline_with_mixed_failures( + self, + mock_actor_galaxies, + mock_attack_tags, + mock_resolve_relations + ): + """Test full pipeline with multiple instances and mixed object failures. + + This is a comprehensive integration test that verifies: + - Multiple MISP instances are processed + - Failures on one instance don't affect others + - Individual object failures don't halt instance processing + + **Validates: Requirements 8.5, 8.6** + """ + mock_resolve_relations.return_value = [] + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + # Create two mock clients + client1 = MagicMock() + client2 = MagicMock() + + # Create objects + obj1_uuid = make_test_uuid() + obj2_uuid = make_test_uuid() + + objects = [ + (obj1_uuid, "tvm", make_opentide_object(obj1_uuid, "Object 1", "green", 1, "tvm")), + (obj2_uuid, "tvm", make_opentide_object(obj2_uuid, "Object 2", "green", 1, "tvm")), + ] + all_objects = make_all_objects(objects) + + # Configure client1: first object fails, second succeeds + client1.search.return_value = [] + client1_call_count = [0] + + def client1_add_event(event, **kwargs): + client1_call_count[0] += 1 + if client1_call_count[0] == 1: + raise Exception("Instance 1 API Error") + result = MISPEvent() + result.uuid = event.uuid + result.id = "12345" + return result + + client1.add_event.side_effect = client1_add_event + + # Configure client2: all succeed + client2.search.return_value = [] + + def client2_add_event(event, **kwargs): + result = MISPEvent() + result.uuid = event.uuid + result.id = "67890" + return result + + client2.add_event.side_effect = client2_add_event + + # Create instance configs + instance1 = make_misp_instance_config(name="Instance 1") + instance2 = make_misp_instance_config(name="Instance 2") + + # Simulate processing both instances + instances_results = {} + + for instance_config, client in [(instance1, client1), (instance2, client2)]: + scope = compute_sharing_scope(instance_config, all_objects) + counters = {"shared": 0, "failed": 0} + + for scoped_obj in scope: + success = create_event( + client=client, + instance_config=instance_config, + object_uuid=scoped_obj.uuid, + object_type=scoped_obj.object_type, + object_data=scoped_obj.data, + object_name=scoped_obj.name, + tlp=scoped_obj.tlp + ) + + if success: + counters["shared"] += 1 + else: + counters["failed"] += 1 + + instances_results[instance_config.name] = counters + + # Verify Instance 1: 1 failed, 1 succeeded + assert instances_results["Instance 1"]["failed"] == 1 + assert instances_results["Instance 1"]["shared"] == 1 + + # Verify Instance 2: all succeeded + assert instances_results["Instance 2"]["failed"] == 0 + assert instances_results["Instance 2"]["shared"] == 2 + + +# ============================================================================ +# Integration Tests: Duplicate Event Handling +# ============================================================================ + +class TestDuplicateEventHandling: + """Test handling of duplicate MISP events. + + **Validates: Requirements 3.3** + """ + + def test_multiple_existing_events_uses_most_recent(self): + """Test that when multiple matching events exist, the most recent is used. + + **Validates: Requirements 3.3** + - IF the existence query returns more than one matching MISP_Event, + THEN THE Sharing_Engine SHALL use the most recently modified event + """ + mock_client = MagicMock() + + obj_uuid = make_test_uuid() + org_uuid = make_test_uuid() + + # Create two events with different timestamps + event1 = make_mock_misp_event(derive_event_uuid(obj_uuid), obj_uuid, version=2) + event1.timestamp = 1000000000 # Older + + event2 = make_mock_misp_event(derive_event_uuid(obj_uuid), obj_uuid, version=3) + event2.timestamp = 2000000000 # Newer + + # Return both events + mock_client.search.return_value = [event1, event2] + + result = check_existence(mock_client, org_uuid, obj_uuid) + + assert result.found, "Should find existing event" + assert result.remote_version == 3, "Should use version from most recent event" + assert result.event.timestamp == 2000000000, "Should select the most recent event" + + +# ============================================================================ +# Integration Tests: Object Type Handling +# ============================================================================ + +class TestObjectTypeHandling: + """Test handling of different object types (TVM, DOM, MDR).""" + + @patch("Engines.sharing.events.resolve_relations") + @patch("Engines.sharing.events.build_attack_tags") + @patch("Engines.sharing.events.build_actor_galaxies") + def test_all_object_types_processed_correctly( + self, + mock_actor_galaxies, + mock_attack_tags, + mock_resolve_relations + ): + """Test that TVM, DOM, and MDR objects are all processed correctly.""" + mock_resolve_relations.return_value = [] + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + mock_client = MagicMock() + mock_client.search.return_value = [] + + def mock_add_event(event, **kwargs): + result = MISPEvent() + result.uuid = event.uuid + result.id = "12345" + return result + + mock_client.add_event.side_effect = mock_add_event + + instance_config = make_misp_instance_config() + + # Test each object type + for obj_type in ["tvm", "dom", "mdr"]: + obj_uuid = make_test_uuid() + obj_data = make_opentide_object(obj_uuid, f"Test {obj_type.upper()}", "green", 1, obj_type) + + success = create_event( + client=mock_client, + instance_config=instance_config, + object_uuid=obj_uuid, + object_type=obj_type, + object_data=obj_data, + object_name=f"Test {obj_type.upper()}", + tlp=TLPLevel.GREEN + ) + + assert success, f"Should successfully create event for {obj_type}" + + assert mock_client.add_event.call_count == 3, "Should create events for all 3 object types" + + +# ============================================================================ +# Integration Tests: Deterministic Event UUID +# ============================================================================ + +class TestDeterministicEventUUID: + """Test that event UUIDs are derived deterministically. + + **Validates: Requirements 5.9** + """ + + @patch("Engines.sharing.events.resolve_relations") + @patch("Engines.sharing.events.build_attack_tags") + @patch("Engines.sharing.events.build_actor_galaxies") + def test_same_object_gets_same_event_uuid_across_instances( + self, + mock_actor_galaxies, + mock_attack_tags, + mock_resolve_relations + ): + """Test that the same object gets the same event UUID regardless of instance. + + **Validates: Requirements 5.9** + """ + mock_resolve_relations.return_value = [] + mock_attack_tags.return_value = [] + mock_actor_galaxies.return_value = [] + + obj_uuid = make_test_uuid() + obj_data = make_opentide_object(obj_uuid, "Test Object", "green", 1, "tvm") + + created_event_uuids = [] + + # Create events on two different instances + for instance_name in ["Instance 1", "Instance 2"]: + mock_client = MagicMock() + mock_client.search.return_value = [] + + def mock_add_event(event, **kwargs): + created_event_uuids.append(event.uuid) + result = MISPEvent() + result.uuid = event.uuid + result.id = "12345" + return result + + mock_client.add_event.side_effect = mock_add_event + + instance_config = make_misp_instance_config(name=instance_name) + + create_event( + client=mock_client, + instance_config=instance_config, + object_uuid=obj_uuid, + object_type="tvm", + object_data=obj_data, + object_name="Test Object", + tlp=TLPLevel.GREEN + ) + + assert len(created_event_uuids) == 2, "Should create events on both instances" + assert created_event_uuids[0] == created_event_uuids[1], \ + "Event UUIDs should be identical across instances" + assert created_event_uuids[0] == derive_event_uuid(obj_uuid), \ + "Event UUID should match the deterministic derivation" diff --git a/tests/sharing/test_orchestrator_flow.py b/tests/sharing/test_orchestrator_flow.py new file mode 100644 index 00000000..258973e3 --- /dev/null +++ b/tests/sharing/test_orchestrator_flow.py @@ -0,0 +1,971 @@ +"""Unit tests for orchestrator CI conditions and flow. + +This module tests the MISP sharing pipeline orchestrator's behavior under +different CI environments, including PR/MR event skipping, default branch +push execution, LocalDebug execution, and disabled organisation handling. + +**Validates: Requirements 7.1, 7.2, 7.4, 7.5, 7.6, 7.7** +""" + +import sys +import uuid +import os +from unittest.mock import patch, MagicMock + +import git +import pytest + +# Add project root to path for imports +sys.path.insert(0, str(git.Repo(".", search_parent_directories=True).working_dir)) + +from Engines.modules.deployment import CIEnvironment +from Engines.modules.sharing import ( + TLPLevel, + MISPInstanceConfig, + OrganisationConfig, + SharingConfig, +) +from Orchestration.share import ( + _is_pr_or_mr_event, + _is_default_branch_push, + _collect_all_objects, + _count_by_type, + main, +) + + +# ============================================================================ +# Helper factories +# ============================================================================ + +def make_sharing_config( + enabled: bool = True, + instances: list = None +) -> SharingConfig: + """Create a SharingConfig for testing.""" + if instances is None: + instances = [] + return SharingConfig( + organisation=OrganisationConfig( + enabled=enabled, + name="Test Organisation", + uuid=str(uuid.uuid4()) + ), + instances=instances + ) + + +def make_misp_instance_config(name: str = "Test MISP") -> MISPInstanceConfig: + """Create a MISPInstanceConfig for testing.""" + return MISPInstanceConfig( + name=name, + url="https://misp.example.org", + token="test-token", + org_uuid=str(uuid.uuid4()), + max_allowed_tlp=TLPLevel.AMBER, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True + ) + + +# ============================================================================ +# Tests for _is_pr_or_mr_event +# ============================================================================ + +class TestPRMREventDetection: + """Test detection of PR/MR events across CI platforms. + + **Validates: Requirements 7.4** + + THE Sharing_Engine SHALL skip all sharing operations without error + if the pipeline is triggered during a pull request or merge request event. + """ + + def test_github_actions_pr_event_detected(self): + """Test that GitHub Actions PR events are detected. + + **Validates: Requirements 7.4** + """ + ci_env = CIEnvironment.CIPlatforms.GitHubActions + + with patch.dict(os.environ, { + "GITHUB_EVENT_NAME": "pull_request" + }, clear=False): + assert _is_pr_or_mr_event(ci_env) is True + + def test_github_actions_pr_target_event_detected(self): + """Test that GitHub Actions pull_request_target events are detected. + + **Validates: Requirements 7.4** + """ + ci_env = CIEnvironment.CIPlatforms.GitHubActions + + with patch.dict(os.environ, { + "GITHUB_EVENT_NAME": "pull_request_target" + }, clear=False): + assert _is_pr_or_mr_event(ci_env) is True + + def test_github_actions_push_event_not_pr(self): + """Test that GitHub Actions push events are not PR events. + + **Validates: Requirements 7.4** + """ + ci_env = CIEnvironment.CIPlatforms.GitHubActions + + with patch.dict(os.environ, { + "GITHUB_EVENT_NAME": "push" + }, clear=False): + assert _is_pr_or_mr_event(ci_env) is False + + def test_github_actions_no_event_name(self): + """Test that missing GITHUB_EVENT_NAME is not a PR event.""" + ci_env = CIEnvironment.CIPlatforms.GitHubActions + + # Remove GITHUB_EVENT_NAME if it exists + env = {k: v for k, v in os.environ.items() if k != "GITHUB_EVENT_NAME"} + with patch.dict(os.environ, env, clear=True): + assert _is_pr_or_mr_event(ci_env) is False + + def test_gitlab_ci_mr_event_detected(self): + """Test that GitLab CI MR events are detected. + + **Validates: Requirements 7.4** + """ + ci_env = CIEnvironment.CIPlatforms.GitlabCI + + with patch.dict(os.environ, { + "CI_MERGE_REQUEST_ID": "123" + }, clear=False): + assert _is_pr_or_mr_event(ci_env) is True + + def test_gitlab_ci_non_mr_event(self): + """Test that GitLab CI non-MR events are not detected as PR events.""" + ci_env = CIEnvironment.CIPlatforms.GitlabCI + + # Remove CI_MERGE_REQUEST_ID if it exists + env = {k: v for k, v in os.environ.items() if k != "CI_MERGE_REQUEST_ID"} + with patch.dict(os.environ, env, clear=True): + assert _is_pr_or_mr_event(ci_env) is False + + def test_azure_pipelines_pr_event_detected(self): + """Test that Azure Pipelines PR events are detected. + + **Validates: Requirements 7.4** + """ + ci_env = CIEnvironment.CIPlatforms.AzurePipeline + + with patch.dict(os.environ, { + "BUILD_REASON": "PullRequest" + }, clear=False): + assert _is_pr_or_mr_event(ci_env) is True + + def test_azure_pipelines_non_pr_event(self): + """Test that Azure Pipelines non-PR events are not detected as PR events.""" + ci_env = CIEnvironment.CIPlatforms.AzurePipeline + + with patch.dict(os.environ, { + "BUILD_REASON": "IndividualCI" + }, clear=False): + assert _is_pr_or_mr_event(ci_env) is False + + def test_local_debug_never_pr_event(self): + """Test that LocalDebug is never considered a PR event.""" + ci_env = CIEnvironment.CIPlatforms.LocalDebug + + assert _is_pr_or_mr_event(ci_env) is False + + +# ============================================================================ +# Tests for _is_default_branch_push +# ============================================================================ + +class TestDefaultBranchPushDetection: + """Test detection of default branch pushes across CI platforms. + + **Validates: Requirements 7.5** + + THE Sharing_Engine SHALL execute the full sharing operation sequence + when the pipeline is triggered by a push to the repository default branch. + """ + + def test_github_actions_push_to_main(self): + """Test that push to main branch is detected. + + **Validates: Requirements 7.5** + """ + ci_env = CIEnvironment.CIPlatforms.GitHubActions + + with patch.dict(os.environ, { + "GITHUB_EVENT_NAME": "push", + "GITHUB_REF_NAME": "main" + }, clear=False): + assert _is_default_branch_push(ci_env) is True + + def test_github_actions_push_to_master(self): + """Test that push to master branch is detected.""" + ci_env = CIEnvironment.CIPlatforms.GitHubActions + + with patch.dict(os.environ, { + "GITHUB_EVENT_NAME": "push", + "GITHUB_REF_NAME": "master" + }, clear=False): + assert _is_default_branch_push(ci_env) is True + + def test_github_actions_push_to_development(self): + """Test that push to development branch is detected.""" + ci_env = CIEnvironment.CIPlatforms.GitHubActions + + with patch.dict(os.environ, { + "GITHUB_EVENT_NAME": "push", + "GITHUB_REF_NAME": "development" + }, clear=False): + assert _is_default_branch_push(ci_env) is True + + def test_github_actions_non_push_event(self): + """Test that non-push events return False.""" + ci_env = CIEnvironment.CIPlatforms.GitHubActions + + with patch.dict(os.environ, { + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REF_NAME": "main" + }, clear=False): + assert _is_default_branch_push(ci_env) is False + + def test_gitlab_ci_push_to_default_branch(self): + """Test that GitLab CI push to default branch is detected. + + **Validates: Requirements 7.5** + """ + ci_env = CIEnvironment.CIPlatforms.GitlabCI + + with patch.dict(os.environ, { + "CI_COMMIT_BRANCH": "main", + "CI_DEFAULT_BRANCH": "main" + }, clear=False): + assert _is_default_branch_push(ci_env) is True + + def test_gitlab_ci_push_to_non_default_branch(self): + """Test that GitLab CI push to non-default branch returns False.""" + ci_env = CIEnvironment.CIPlatforms.GitlabCI + + with patch.dict(os.environ, { + "CI_COMMIT_BRANCH": "feature-branch", + "CI_DEFAULT_BRANCH": "main" + }, clear=False): + assert _is_default_branch_push(ci_env) is False + + def test_gitlab_ci_missing_branch_env_vars(self): + """Test that missing GitLab CI env vars return False.""" + ci_env = CIEnvironment.CIPlatforms.GitlabCI + + # Remove the env vars if they exist + env = {k: v for k, v in os.environ.items() + if k not in ("CI_COMMIT_BRANCH", "CI_DEFAULT_BRANCH")} + with patch.dict(os.environ, env, clear=True): + assert _is_default_branch_push(ci_env) is False + + def test_azure_pipelines_push_to_main(self): + """Test that Azure Pipelines push to main is detected. + + **Validates: Requirements 7.5** + """ + ci_env = CIEnvironment.CIPlatforms.AzurePipeline + + with patch.dict(os.environ, { + "BUILD_REASON": "IndividualCI", + "BUILD_SOURCEBRANCH": "refs/heads/main" + }, clear=False): + assert _is_default_branch_push(ci_env) is True + + def test_azure_pipelines_push_to_master(self): + """Test that Azure Pipelines push to master is detected.""" + ci_env = CIEnvironment.CIPlatforms.AzurePipeline + + with patch.dict(os.environ, { + "BUILD_REASON": "BatchedCI", + "BUILD_SOURCEBRANCH": "refs/heads/master" + }, clear=False): + assert _is_default_branch_push(ci_env) is True + + def test_azure_pipelines_manual_trigger(self): + """Test that Azure Pipelines manual triggers work.""" + ci_env = CIEnvironment.CIPlatforms.AzurePipeline + + with patch.dict(os.environ, { + "BUILD_REASON": "Manual", + "BUILD_SOURCEBRANCH": "refs/heads/main" + }, clear=False): + assert _is_default_branch_push(ci_env) is True + + def test_azure_pipelines_pr_build_reason(self): + """Test that Azure Pipelines PR builds return False.""" + ci_env = CIEnvironment.CIPlatforms.AzurePipeline + + with patch.dict(os.environ, { + "BUILD_REASON": "PullRequest", + "BUILD_SOURCEBRANCH": "refs/heads/main" + }, clear=False): + assert _is_default_branch_push(ci_env) is False + + def test_local_debug_returns_false(self): + """Test that LocalDebug always returns False from this function.""" + ci_env = CIEnvironment.CIPlatforms.LocalDebug + + assert _is_default_branch_push(ci_env) is False + + +# ============================================================================ +# Tests for LocalDebug execution +# ============================================================================ + +class TestLocalDebugExecution: + """Test LocalDebug execution without branch conditions. + + **Validates: Requirements 7.7** + + WHILE the CIEnvironment is detected as LocalDebug, THE Sharing_Engine SHALL + execute sharing operations against all configured MISP instances without + requiring a CI branch condition. + """ + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_local_debug_executes_without_branch_check( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_ci_env_class + ): + """Test that LocalDebug skips branch conditions and executes sharing. + + **Validates: Requirements 7.7** + """ + # Setup mocks + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.LocalDebug + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + # Setup sharing config with instances + instance_config = make_misp_instance_config() + sharing_config = make_sharing_config(enabled=True, instances=[instance_config]) + mock_load_config.return_value = sharing_config + + # Setup DataTide + mock_datatide.Configurations.Index.get.return_value = {} + mock_datatide.Models.tvm = {"uuid1": {"name": "TVM1", "metadata": {"uuid": "uuid1"}}} + mock_datatide.Models.dom = {} + mock_datatide.Models.mdr = {} + + # Run main + main() + + # Verify that _process_instance was called (sharing executed) + assert mock_process_instance.called, "LocalDebug should execute sharing without branch condition" + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_local_debug_processes_all_instances( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_ci_env_class + ): + """Test that LocalDebug processes all configured MISP instances. + + **Validates: Requirements 7.7** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.LocalDebug + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + # Setup sharing config with multiple instances + instance1 = make_misp_instance_config("MISP Instance 1") + instance2 = make_misp_instance_config("MISP Instance 2") + sharing_config = make_sharing_config(enabled=True, instances=[instance1, instance2]) + mock_load_config.return_value = sharing_config + + mock_datatide.Configurations.Index.get.return_value = {} + mock_datatide.Models.tvm = {} + mock_datatide.Models.dom = {} + mock_datatide.Models.mdr = {} + + main() + + # Verify _process_instance was called for each instance + assert mock_process_instance.call_count == 2, "Should process all MISP instances" + + +# ============================================================================ +# Tests for disabled organisation exit +# ============================================================================ + +class TestDisabledOrganisationExit: + """Test that disabled organisation exits gracefully. + + **Validates: Requirements 1.9, 7.6** + + WHEN `organisation.enabled` is `false`, THE Sharing_Engine SHALL skip + all sharing operations and log a message at INFO level indicating + sharing is disabled. + """ + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_disabled_organisation_skips_sharing( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_ci_env_class + ): + """Test that disabled organisation skips all sharing operations. + + **Validates: Requirements 1.9** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.LocalDebug + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + # Setup sharing config with disabled organisation + sharing_config = make_sharing_config(enabled=False, instances=[make_misp_instance_config()]) + mock_load_config.return_value = sharing_config + + mock_datatide.Configurations.Index.get.return_value = {} + + main() + + # Verify that _process_instance was NOT called + assert not mock_process_instance.called, "Disabled organisation should skip sharing" + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_disabled_organisation_logs_info_message( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_ci_env_class + ): + """Test that disabled organisation logs INFO message. + + **Validates: Requirements 1.9** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.LocalDebug + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + sharing_config = make_sharing_config(enabled=False) + mock_load_config.return_value = sharing_config + + mock_datatide.Configurations.Index.get.return_value = {} + + main() + + # Verify INFO log was called with appropriate message + log_calls = [call for call in mock_log.call_args_list + if call[0][0] == "INFO" and "disabled" in call[0][1].lower()] + assert len(log_calls) > 0, "Should log INFO message about disabled sharing" + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_disabled_organisation_exits_without_error( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_ci_env_class + ): + """Test that disabled organisation exits gracefully without raising. + + **Validates: Requirements 1.9** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.LocalDebug + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + sharing_config = make_sharing_config(enabled=False) + mock_load_config.return_value = sharing_config + + mock_datatide.Configurations.Index.get.return_value = {} + + # Should not raise any exception + main() + + +# ============================================================================ +# Tests for PR/MR event skipping +# ============================================================================ + +class TestPRMREventSkipping: + """Test that PR/MR events cause the pipeline to skip. + + **Validates: Requirements 7.4** + """ + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share._is_pr_or_mr_event") + @patch("Orchestration.share._is_default_branch_push") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_github_pr_event_skips_sharing( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_is_default_push, + mock_is_pr_mr, + mock_ci_env_class + ): + """Test that GitHub Actions PR events skip sharing. + + **Validates: Requirements 7.4** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.GitHubActions + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + mock_is_pr_mr.return_value = True + + main() + + # Verify load_sharing_config was NOT called (exited early) + assert not mock_load_config.called, "PR event should skip sharing before loading config" + assert not mock_process_instance.called, "PR event should not process instances" + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share._is_pr_or_mr_event") + @patch("Orchestration.share._is_default_branch_push") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_gitlab_mr_event_skips_sharing( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_is_default_push, + mock_is_pr_mr, + mock_ci_env_class + ): + """Test that GitLab CI MR events skip sharing. + + **Validates: Requirements 7.4** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.GitlabCI + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + mock_is_pr_mr.return_value = True + + main() + + assert not mock_load_config.called, "MR event should skip sharing before loading config" + assert not mock_process_instance.called, "MR event should not process instances" + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share._is_pr_or_mr_event") + @patch("Orchestration.share._is_default_branch_push") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_pr_event_logs_skip_message( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_is_default_push, + mock_is_pr_mr, + mock_ci_env_class + ): + """Test that PR event logs an INFO skip message. + + **Validates: Requirements 7.4** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.GitHubActions + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + mock_is_pr_mr.return_value = True + + main() + + # Verify INFO log about skipping was called + # Check all arguments in log calls for PR/MR related keywords + log_calls = [call for call in mock_log.call_args_list + if call[0][0] == "INFO" and any( + "pull request" in str(arg).lower() or "merge request" in str(arg).lower() + for arg in call[0] + )] + assert len(log_calls) > 0, "Should log INFO about PR/MR event skip" + + +# ============================================================================ +# Tests for default branch push execution +# ============================================================================ + +class TestDefaultBranchPushExecution: + """Test that default branch pushes execute the full sharing sequence. + + **Validates: Requirements 7.5** + """ + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share._is_pr_or_mr_event") + @patch("Orchestration.share._is_default_branch_push") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_default_branch_push_executes_sharing( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_is_default_push, + mock_is_pr_mr, + mock_ci_env_class + ): + """Test that default branch pushes execute the full sharing sequence. + + **Validates: Requirements 7.5** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.GitHubActions + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + mock_is_pr_mr.return_value = False + mock_is_default_push.return_value = True + + instance_config = make_misp_instance_config() + sharing_config = make_sharing_config(enabled=True, instances=[instance_config]) + mock_load_config.return_value = sharing_config + + mock_datatide.Configurations.Index.get.return_value = {} + mock_datatide.Models.tvm = {} + mock_datatide.Models.dom = {} + mock_datatide.Models.mdr = {} + + main() + + # Verify load_sharing_config was called + assert mock_load_config.called, "Default branch push should load config" + # Verify _process_instance was called + assert mock_process_instance.called, "Default branch push should process instances" + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share._is_pr_or_mr_event") + @patch("Orchestration.share._is_default_branch_push") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_non_default_branch_push_skips_sharing( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_is_default_push, + mock_is_pr_mr, + mock_ci_env_class + ): + """Test that non-default branch pushes skip sharing. + + **Validates: Requirements 7.5** + """ + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.GitHubActions + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + mock_is_pr_mr.return_value = False + mock_is_default_push.return_value = False + + main() + + # Verify load_sharing_config was NOT called + assert not mock_load_config.called, "Non-default branch push should skip sharing" + assert not mock_process_instance.called, "Non-default branch push should not process instances" + + +# ============================================================================ +# Tests for CI environment detection +# ============================================================================ + +class TestCIEnvironmentDetection: + """Test CI environment detection. + + **Validates: Requirements 7.1, 7.2** + """ + + def test_github_actions_detected_via_env_var(self): + """Test that GitHub Actions is detected via GITHUB_ACTIONS env var. + + **Validates: Requirements 7.2** + """ + with patch.dict(os.environ, { + "GITHUB_ACTIONS": "true" + }, clear=False): + with patch("Engines.modules.deployment.HelperTide.is_debug", return_value=False): + with patch("Engines.modules.deployment.log"): + env = CIEnvironment() + assert env.environment == CIEnvironment.CIPlatforms.GitHubActions + + def test_gitlab_ci_detected_via_env_var(self): + """Test that GitLab CI is detected via CI env var. + + **Validates: Requirements 7.2** + """ + # Clear GITHUB_ACTIONS and TF_BUILD to ensure GitLab CI is detected + env_vars = {k: v for k, v in os.environ.items() + if k not in ("GITHUB_ACTIONS", "TF_BUILD")} + env_vars["CI"] = "true" + + with patch.dict(os.environ, env_vars, clear=True): + with patch("Engines.modules.deployment.HelperTide.is_debug", return_value=False): + with patch("Engines.modules.deployment.log"): + env = CIEnvironment() + assert env.environment == CIEnvironment.CIPlatforms.GitlabCI + + def test_azure_pipelines_detected_via_env_var(self): + """Test that Azure Pipelines is detected via TF_BUILD env var. + + **Validates: Requirements 7.2** + """ + # Clear GITHUB_ACTIONS to ensure Azure is detected first + env_vars = {k: v for k, v in os.environ.items() + if k not in ("GITHUB_ACTIONS",)} + env_vars["TF_BUILD"] = "True" + + with patch.dict(os.environ, env_vars, clear=True): + with patch("Engines.modules.deployment.HelperTide.is_debug", return_value=False): + with patch("Engines.modules.deployment.log"): + env = CIEnvironment() + assert env.environment == CIEnvironment.CIPlatforms.AzurePipeline + + def test_local_debug_detected_when_debug_enabled(self): + """Test that LocalDebug is detected when debug mode is enabled. + + **Validates: Requirements 7.2** + """ + # Clear all CI-related env vars + env_vars = {k: v for k, v in os.environ.items() + if k not in ("GITHUB_ACTIONS", "TF_BUILD", "CI")} + + with patch.dict(os.environ, env_vars, clear=True): + with patch("Engines.modules.deployment.HelperTide.is_debug", return_value=True): + with patch("Engines.modules.deployment.log"): + env = CIEnvironment() + assert env.environment == CIEnvironment.CIPlatforms.LocalDebug + + +# ============================================================================ +# Tests for object collection +# ============================================================================ + +class TestObjectCollection: + """Test object collection from DataTide.""" + + @patch("Orchestration.share.DataTide") + def test_collect_all_objects_merges_tvm_dom_mdr(self, mock_datatide): + """Test that _collect_all_objects merges TVM, DOM, MDR objects.""" + mock_datatide.Models.tvm = { + "uuid1": {"name": "TVM1", "metadata": {"uuid": "uuid1"}} + } + mock_datatide.Models.dom = { + "uuid2": {"name": "DOM1", "metadata": {"uuid": "uuid2"}} + } + mock_datatide.Models.mdr = { + "uuid3": {"name": "MDR1", "metadata": {"uuid": "uuid3"}} + } + + result = _collect_all_objects() + + assert "uuid1" in result + assert "uuid2" in result + assert "uuid3" in result + assert result["uuid1"][0] == "tvm" + assert result["uuid2"][0] == "dom" + assert result["uuid3"][0] == "mdr" + + @patch("Orchestration.share.DataTide") + def test_collect_all_objects_handles_empty_indices(self, mock_datatide): + """Test that _collect_all_objects handles empty indices.""" + mock_datatide.Models.tvm = {} + mock_datatide.Models.dom = {} + mock_datatide.Models.mdr = {} + + result = _collect_all_objects() + + assert result == {} + + @patch("Orchestration.share.DataTide") + def test_collect_all_objects_handles_none_indices(self, mock_datatide): + """Test that _collect_all_objects handles None indices.""" + mock_datatide.Models.tvm = None + mock_datatide.Models.dom = None + mock_datatide.Models.mdr = None + + result = _collect_all_objects() + + assert result == {} + + def test_count_by_type(self): + """Test _count_by_type helper function.""" + all_objects = { + "uuid1": ("tvm", {}), + "uuid2": ("tvm", {}), + "uuid3": ("dom", {}), + "uuid4": ("mdr", {}), + } + + assert _count_by_type(all_objects, "tvm") == 2 + assert _count_by_type(all_objects, "dom") == 1 + assert _count_by_type(all_objects, "mdr") == 1 + + +# ============================================================================ +# Tests for no instances configured +# ============================================================================ + +class TestNoInstancesConfigured: + """Test handling when no MISP instances are configured.""" + + @patch("Orchestration.share.CIEnvironment") + @patch("Orchestration.share.load_sharing_config") + @patch("Orchestration.share.DataTide") + @patch("Orchestration.share._process_instance") + @patch("Orchestration.share.log") + @patch("Orchestration.share.print") + @patch("Orchestration.share.coretide_intro") + def test_no_instances_logs_info_and_exits( + self, + mock_coretide_intro, + mock_print, + mock_log, + mock_process_instance, + mock_datatide, + mock_load_config, + mock_ci_env_class + ): + """Test that no instances configured logs INFO and exits gracefully.""" + mock_coretide_intro.return_value = "" + mock_ci_env_instance = MagicMock() + mock_ci_env_instance.environment = CIEnvironment.CIPlatforms.LocalDebug + mock_ci_env_class.return_value = mock_ci_env_instance + mock_ci_env_class.CIPlatforms = CIEnvironment.CIPlatforms + + # No instances + sharing_config = make_sharing_config(enabled=True, instances=[]) + mock_load_config.return_value = sharing_config + + mock_datatide.Configurations.Index.get.return_value = {} + mock_datatide.Models.tvm = {} + mock_datatide.Models.dom = {} + mock_datatide.Models.mdr = {} + + main() + + # Verify _process_instance was NOT called + assert not mock_process_instance.called, "No instances should mean no processing" + + # Verify INFO log about no instances + log_calls = [call for call in mock_log.call_args_list + if call[0][0] == "INFO" and "no" in call[0][1].lower() + and "instance" in call[0][1].lower()] + assert len(log_calls) > 0, "Should log INFO about no instances configured" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/sharing/test_relation_properties.py b/tests/sharing/test_relation_properties.py new file mode 100644 index 00000000..bc6723de --- /dev/null +++ b/tests/sharing/test_relation_properties.py @@ -0,0 +1,683 @@ +"""Property-based tests for relation resolution correctness. + +This module tests Property 9 (Non-TVM relation resolution correctness) +using Hypothesis. + +**Validates: Requirements 5.6, 5.7, 5.8** +""" + +import sys +import uuid +from typing import List, Optional + +import git +import pytest +from hypothesis import given, settings, assume, note +from hypothesis import strategies as st + +# Add project root to path for imports +sys.path.insert(0, str(git.Repo(".", search_parent_directories=True).working_dir)) + +from Engines.sharing.relations import resolve_relations, _resolve_dom_relations, _resolve_mdr_relations + + +# ============================================================================ +# Hypothesis Strategies for generating test data +# ============================================================================ + +@st.composite +def valid_uuid(draw) -> str: + """Generate a valid UUIDv4 string.""" + return str(uuid.uuid4()) + + +@st.composite +def uuid_list(draw, min_size: int = 0, max_size: int = 10) -> List[str]: + """Generate a list of valid UUIDv4 strings.""" + return draw(st.lists(valid_uuid(), min_size=min_size, max_size=max_size)) + + +@st.composite +def dom_object_with_threats(draw, threats: Optional[List[str]] = None) -> dict: + """Generate a DOM object with a specific threats list. + + Args: + threats: Optional specific threats list. If None, randomly generated. + """ + if threats is None: + threats = draw(uuid_list()) + + obj_uuid = draw(valid_uuid()) + + # Build the objective section with threats + objective = {} + if threats: + objective["threats"] = threats + + obj_data = { + "uuid": obj_uuid, + "name": f"Test DOM {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": "green", + "version": draw(st.integers(min_value=1, max_value=100)) + } + } + + # Only add objective if we have threats to add (or randomly add empty objective) + if threats or draw(st.booleans()): + obj_data["objective"] = objective + + return obj_data + + +@st.composite +def dom_object_with_specific_threats(draw, threats: List[str]) -> dict: + """Generate a DOM object with exactly the specified threats list.""" + obj_uuid = draw(valid_uuid()) + + obj_data = { + "uuid": obj_uuid, + "name": f"Test DOM {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": "green", + "version": draw(st.integers(min_value=1, max_value=100)) + }, + "objective": { + "threats": threats + } + } + + return obj_data + + +@st.composite +def mdr_object_with_detection_model(draw, detection_model: Optional[str] = None) -> dict: + """Generate an MDR object with a specific detection_model. + + Args: + detection_model: Optional specific detection_model UUID. If None, randomly generated. + """ + if detection_model is None: + # Randomly decide whether to include a detection_model + include_model = draw(st.booleans()) + detection_model = draw(valid_uuid()) if include_model else None + + obj_uuid = draw(valid_uuid()) + + obj_data = { + "uuid": obj_uuid, + "name": f"Test MDR {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": "amber", + "version": draw(st.integers(min_value=1, max_value=100)) + } + } + + if detection_model is not None: + obj_data["detection_model"] = detection_model + + return obj_data + + +@st.composite +def mdr_object_with_specific_detection_model(draw, detection_model: str) -> dict: + """Generate an MDR object with exactly the specified detection_model UUID.""" + obj_uuid = draw(valid_uuid()) + + return { + "uuid": obj_uuid, + "name": f"Test MDR {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": "amber", + "version": draw(st.integers(min_value=1, max_value=100)) + }, + "detection_model": detection_model + } + + +# ============================================================================ +# Property 9: Non-TVM relation resolution correctness +# ============================================================================ + +class TestNonTVMRelationResolutionCorrectness: + """Property 9: Non-TVM relation resolution correctness. + + **Validates: Requirements 5.6, 5.7, 5.8** + + Property Statement: + *For any* DOM object, `resolve_relations()` SHALL return exactly the UUIDs + from `objective.threats`. *For any* MDR object, `resolve_relations()` SHALL + return a list containing exactly the `detection_model` UUID. When these + fields are empty or absent, the function SHALL return an empty list. + """ + + # ======================================================================== + # DOM Tests - Validates Requirement 5.6 + # ======================================================================== + + @given(threats=uuid_list(min_size=1, max_size=20)) + @settings(max_examples=100) + def test_dom_returns_exactly_threats_uuids(self, threats: List[str]): + """Test that DOM resolve_relations returns exactly the objective.threats UUIDs. + + **Validates: Requirements 5.6** + + For any DOM object with a non-empty threats list, resolve_relations() + shall return exactly those UUIDs in the same order. + """ + obj_uuid = str(uuid.uuid4()) + + dom_data = { + "uuid": obj_uuid, + "name": f"Test DOM {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": "green", + "version": 1 + }, + "objective": { + "threats": threats + } + } + + result = resolve_relations(obj_uuid, "dom", dom_data) + + # Result should contain exactly the same UUIDs + assert len(result) == len(threats), ( + f"Expected {len(threats)} UUIDs but got {len(result)}. " + f"Expected: {threats}, Got: {result}" + ) + + # Verify each UUID is present and in correct order + for i, expected_uuid in enumerate(threats): + assert result[i] == str(expected_uuid), ( + f"UUID mismatch at index {i}: expected {expected_uuid}, got {result[i]}" + ) + + @given(threats=uuid_list(min_size=1, max_size=10)) + @settings(max_examples=100) + def test_dom_returns_all_threats_without_modification(self, threats: List[str]): + """Test that DOM relations are returned without modification. + + **Validates: Requirements 5.6** + + The returned list should be exactly equal to the threats list + (converted to strings). + """ + obj_uuid = str(uuid.uuid4()) + + dom_data = { + "uuid": obj_uuid, + "name": f"Test DOM", + "metadata": {"uuid": obj_uuid, "tlp": "green", "version": 1}, + "objective": {"threats": threats} + } + + result = resolve_relations(obj_uuid, "dom", dom_data) + expected = [str(t) for t in threats] + + assert result == expected, ( + f"DOM relations mismatch. Expected: {expected}, Got: {result}" + ) + + @given(data=st.data()) + @settings(max_examples=100) + def test_dom_empty_threats_returns_empty_list(self, data): + """Test that DOM with empty threats list returns empty list. + + **Validates: Requirements 5.8** + + When objective.threats is an empty list, resolve_relations() + shall return an empty list. + """ + obj_uuid = str(uuid.uuid4()) + + dom_data = { + "uuid": obj_uuid, + "name": f"Test DOM", + "metadata": {"uuid": obj_uuid, "tlp": "green", "version": 1}, + "objective": { + "threats": [] + } + } + + result = resolve_relations(obj_uuid, "dom", dom_data) + + assert result == [], ( + f"Expected empty list for DOM with empty threats, got: {result}" + ) + + @given(data=st.data()) + @settings(max_examples=50) + def test_dom_absent_threats_returns_empty_list(self, data): + """Test that DOM with absent threats field returns empty list. + + **Validates: Requirements 5.8** + + When objective.threats is absent from the DOM object, + resolve_relations() shall return an empty list. + """ + obj_uuid = str(uuid.uuid4()) + + dom_data = { + "uuid": obj_uuid, + "name": f"Test DOM", + "metadata": {"uuid": obj_uuid, "tlp": "green", "version": 1}, + "objective": {} # objective exists but no threats field + } + + result = resolve_relations(obj_uuid, "dom", dom_data) + + assert result == [], ( + f"Expected empty list for DOM with absent threats, got: {result}" + ) + + @given(data=st.data()) + @settings(max_examples=50) + def test_dom_absent_objective_returns_empty_list(self, data): + """Test that DOM with absent objective section returns empty list. + + **Validates: Requirements 5.8** + + When the entire objective section is absent from the DOM object, + resolve_relations() shall return an empty list. + """ + obj_uuid = str(uuid.uuid4()) + + dom_data = { + "uuid": obj_uuid, + "name": f"Test DOM", + "metadata": {"uuid": obj_uuid, "tlp": "green", "version": 1} + # No objective field at all + } + + result = resolve_relations(obj_uuid, "dom", dom_data) + + assert result == [], ( + f"Expected empty list for DOM with absent objective, got: {result}" + ) + + @given(threats=uuid_list(min_size=1, max_size=5)) + @settings(max_examples=50) + def test_dom_threats_with_extra_objective_fields(self, threats: List[str]): + """Test DOM resolution with additional objective fields is not affected. + + **Validates: Requirements 5.6** + + Extra fields in the objective section should not affect threat resolution. + """ + obj_uuid = str(uuid.uuid4()) + + dom_data = { + "uuid": obj_uuid, + "name": f"Test DOM", + "metadata": {"uuid": obj_uuid, "tlp": "green", "version": 1}, + "objective": { + "threats": threats, + "description": "Some description", + "priority": "high", + "extra_field": ["some", "data"] + } + } + + result = resolve_relations(obj_uuid, "dom", dom_data) + expected = [str(t) for t in threats] + + assert result == expected, ( + f"Extra objective fields affected resolution. Expected: {expected}, Got: {result}" + ) + + # ======================================================================== + # MDR Tests - Validates Requirement 5.7 + # ======================================================================== + + @given(detection_model=valid_uuid()) + @settings(max_examples=100) + def test_mdr_returns_exactly_detection_model_uuid(self, detection_model: str): + """Test that MDR resolve_relations returns exactly the detection_model UUID. + + **Validates: Requirements 5.7** + + For any MDR object with a detection_model field, resolve_relations() + shall return a list containing exactly that single UUID. + """ + obj_uuid = str(uuid.uuid4()) + + mdr_data = { + "uuid": obj_uuid, + "name": f"Test MDR {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": "amber", + "version": 1 + }, + "detection_model": detection_model + } + + result = resolve_relations(obj_uuid, "mdr", mdr_data) + + # Result should be a single-element list with the detection_model UUID + assert len(result) == 1, ( + f"Expected 1 UUID for MDR but got {len(result)}. Result: {result}" + ) + + assert result[0] == str(detection_model), ( + f"MDR detection_model mismatch: expected {detection_model}, got {result[0]}" + ) + + @given(detection_model=valid_uuid()) + @settings(max_examples=100) + def test_mdr_returns_single_element_list(self, detection_model: str): + """Test that MDR always returns a single-element list. + + **Validates: Requirements 5.7** + + The MDR resolution should always produce a list with exactly one element. + """ + obj_uuid = str(uuid.uuid4()) + + mdr_data = { + "uuid": obj_uuid, + "name": f"Test MDR", + "metadata": {"uuid": obj_uuid, "tlp": "red", "version": 1}, + "detection_model": detection_model + } + + result = resolve_relations(obj_uuid, "mdr", mdr_data) + + assert isinstance(result, list), ( + f"Expected list type, got {type(result)}" + ) + assert len(result) == 1, ( + f"Expected exactly 1 element for MDR, got {len(result)}" + ) + assert result == [str(detection_model)], ( + f"MDR result mismatch. Expected: [{detection_model}], Got: {result}" + ) + + @given(data=st.data()) + @settings(max_examples=50) + def test_mdr_absent_detection_model_returns_empty_list(self, data): + """Test that MDR with absent detection_model returns empty list. + + **Validates: Requirements 5.8** + + When detection_model is absent from the MDR object, + resolve_relations() shall return an empty list. + """ + obj_uuid = str(uuid.uuid4()) + + mdr_data = { + "uuid": obj_uuid, + "name": f"Test MDR", + "metadata": {"uuid": obj_uuid, "tlp": "amber", "version": 1} + # No detection_model field + } + + result = resolve_relations(obj_uuid, "mdr", mdr_data) + + assert result == [], ( + f"Expected empty list for MDR with absent detection_model, got: {result}" + ) + + @given(data=st.data()) + @settings(max_examples=50) + def test_mdr_empty_detection_model_returns_empty_list(self, data): + """Test that MDR with empty/None detection_model returns empty list. + + **Validates: Requirements 5.8** + + When detection_model is empty string or None, resolve_relations() + shall return an empty list. + """ + obj_uuid = str(uuid.uuid4()) + + # Test with empty string + mdr_data_empty = { + "uuid": obj_uuid, + "name": f"Test MDR", + "metadata": {"uuid": obj_uuid, "tlp": "amber", "version": 1}, + "detection_model": "" + } + + result_empty = resolve_relations(obj_uuid, "mdr", mdr_data_empty) + + assert result_empty == [], ( + f"Expected empty list for MDR with empty detection_model, got: {result_empty}" + ) + + # Test with None value + mdr_data_none = { + "uuid": obj_uuid, + "name": f"Test MDR", + "metadata": {"uuid": obj_uuid, "tlp": "amber", "version": 1}, + "detection_model": None + } + + result_none = resolve_relations(obj_uuid, "mdr", mdr_data_none) + + assert result_none == [], ( + f"Expected empty list for MDR with None detection_model, got: {result_none}" + ) + + @given(detection_model=valid_uuid()) + @settings(max_examples=50) + def test_mdr_with_extra_fields(self, detection_model: str): + """Test MDR resolution with additional fields is not affected. + + **Validates: Requirements 5.7** + + Extra fields in the MDR object should not affect detection_model resolution. + """ + obj_uuid = str(uuid.uuid4()) + + mdr_data = { + "uuid": obj_uuid, + "name": f"Test MDR", + "metadata": {"uuid": obj_uuid, "tlp": "amber", "version": 1}, + "detection_model": detection_model, + "detection_logic": "Some logic", + "response_actions": ["action1", "action2"], + "extra_field": {"nested": "data"} + } + + result = resolve_relations(obj_uuid, "mdr", mdr_data) + + assert result == [str(detection_model)], ( + f"Extra fields affected MDR resolution. Expected: [{detection_model}], Got: {result}" + ) + + # ======================================================================== + # Combined/Edge Case Tests - Validates Requirements 5.6, 5.7, 5.8 + # ======================================================================== + + @given( + dom_threats=uuid_list(min_size=0, max_size=5), + mdr_model=st.one_of(valid_uuid(), st.none()) + ) + @settings(max_examples=100) + def test_type_dispatch_correctness(self, dom_threats: List[str], mdr_model: Optional[str]): + """Test that resolve_relations correctly dispatches by object type. + + **Validates: Requirements 5.6, 5.7** + + Verify that the same data processed as DOM vs MDR produces different results + based on the object_type parameter. + """ + obj_uuid = str(uuid.uuid4()) + + # Build DOM data + dom_data = { + "uuid": obj_uuid, + "name": f"Test Object", + "metadata": {"uuid": obj_uuid, "tlp": "green", "version": 1}, + "objective": {"threats": dom_threats} + } + if mdr_model: + dom_data["detection_model"] = mdr_model + + dom_result = resolve_relations(obj_uuid, "dom", dom_data) + + # DOM should return threats, not detection_model + expected_dom = [str(t) for t in dom_threats] + assert dom_result == expected_dom, ( + f"DOM returned wrong data. Expected threats: {expected_dom}, Got: {dom_result}" + ) + + # Build MDR data + mdr_data = { + "uuid": obj_uuid, + "name": f"Test Object", + "metadata": {"uuid": obj_uuid, "tlp": "amber", "version": 1} + } + if dom_threats: + mdr_data["objective"] = {"threats": dom_threats} + if mdr_model: + mdr_data["detection_model"] = mdr_model + + mdr_result = resolve_relations(obj_uuid, "mdr", mdr_data) + + # MDR should return detection_model, not threats + expected_mdr = [str(mdr_model)] if mdr_model else [] + assert mdr_result == expected_mdr, ( + f"MDR returned wrong data. Expected model: {expected_mdr}, Got: {mdr_result}" + ) + + @given(data=st.data()) + @settings(max_examples=50) + def test_empty_object_data_returns_empty_list(self, data): + """Test that empty object data returns empty list for DOM and MDR. + + **Validates: Requirements 5.8** + + When object_data is completely empty (or near empty), resolve_relations() + shall return an empty list. + """ + obj_uuid = str(uuid.uuid4()) + + empty_data = {} + + dom_result = resolve_relations(obj_uuid, "dom", empty_data) + assert dom_result == [], ( + f"Expected empty list for DOM with empty data, got: {dom_result}" + ) + + mdr_result = resolve_relations(obj_uuid, "mdr", empty_data) + assert mdr_result == [], ( + f"Expected empty list for MDR with empty data, got: {mdr_result}" + ) + + @given( + threats=uuid_list(min_size=1, max_size=5), + detection_model=valid_uuid() + ) + @settings(max_examples=50) + def test_dom_ignores_detection_model(self, threats: List[str], detection_model: str): + """Test that DOM resolution ignores detection_model field. + + **Validates: Requirements 5.6** + + When a DOM object has both threats and detection_model (unusual but possible), + the DOM resolution should only return the threats. + """ + obj_uuid = str(uuid.uuid4()) + + dom_data = { + "uuid": obj_uuid, + "name": f"Test DOM", + "metadata": {"uuid": obj_uuid, "tlp": "green", "version": 1}, + "objective": {"threats": threats}, + "detection_model": detection_model # Should be ignored for DOM + } + + result = resolve_relations(obj_uuid, "dom", dom_data) + expected = [str(t) for t in threats] + + assert result == expected, ( + f"DOM should ignore detection_model. Expected: {expected}, Got: {result}" + ) + # Ensure detection_model is NOT in the result + assert detection_model not in result, ( + f"DOM included detection_model {detection_model} when it should have been ignored" + ) + + @given( + threats=uuid_list(min_size=1, max_size=5), + detection_model=valid_uuid() + ) + @settings(max_examples=50) + def test_mdr_ignores_objective_threats(self, threats: List[str], detection_model: str): + """Test that MDR resolution ignores objective.threats field. + + **Validates: Requirements 5.7** + + When an MDR object has both detection_model and objective.threats, + the MDR resolution should only return the detection_model. + """ + obj_uuid = str(uuid.uuid4()) + + mdr_data = { + "uuid": obj_uuid, + "name": f"Test MDR", + "metadata": {"uuid": obj_uuid, "tlp": "amber", "version": 1}, + "detection_model": detection_model, + "objective": {"threats": threats} # Should be ignored for MDR + } + + result = resolve_relations(obj_uuid, "mdr", mdr_data) + + assert result == [str(detection_model)], ( + f"MDR should ignore objective.threats. Expected: [{detection_model}], Got: {result}" + ) + # Ensure threats are NOT in the result + for threat in threats: + assert threat not in result, ( + f"MDR included threat {threat} when it should have been ignored" + ) + + @given(data=st.data()) + @settings(max_examples=30) + def test_return_type_is_always_list(self, data): + """Test that resolve_relations always returns a list (not None or other types). + + **Validates: Requirements 5.6, 5.7, 5.8** + + The function should always return a list, even when empty. + """ + obj_uuid = str(uuid.uuid4()) + + # Test DOM variants + dom_cases = [ + {}, + {"objective": {}}, + {"objective": {"threats": []}}, + {"objective": {"threats": [str(uuid.uuid4())]}}, + ] + + for dom_data in dom_cases: + result = resolve_relations(obj_uuid, "dom", dom_data) + assert isinstance(result, list), ( + f"DOM resolve_relations should return list, got {type(result)}" + ) + + # Test MDR variants + mdr_cases = [ + {}, + {"detection_model": None}, + {"detection_model": ""}, + {"detection_model": str(uuid.uuid4())}, + ] + + for mdr_data in mdr_cases: + result = resolve_relations(obj_uuid, "mdr", mdr_data) + assert isinstance(result, list), ( + f"MDR resolve_relations should return list, got {type(result)}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/sharing/test_tagging_properties.py b/tests/sharing/test_tagging_properties.py new file mode 100644 index 00000000..938bb097 --- /dev/null +++ b/tests/sharing/test_tagging_properties.py @@ -0,0 +1,1091 @@ +"""Property-based tests for MISP event tagging. + +This module tests: +- Property 11 (TLP tag format correctness) +- Property 12 (ATT&CK technique tag completeness) +- Property 13 (Threat actor galaxy attachment priority) + +**Validates: Requirements 6.1, 6.2, 6.3, 6.4** +""" + +import re +import sys +import uuid +from unittest.mock import patch, MagicMock + +import git +import pytest +from hypothesis import given, settings, assume +from hypothesis import strategies as st + +# Add project root to path for imports +sys.path.insert(0, str(git.Repo(".", search_parent_directories=True).working_dir)) + +from Engines.modules.sharing import TLPLevel +from Engines.sharing.tagging import build_tlp_tag, build_attack_tags, build_actor_galaxies + + +# Strategies for generating test data +tlp_levels = st.sampled_from(list(TLPLevel)) + +# Strategy for generating valid ATT&CK technique identifiers +# ATT&CK technique IDs follow the pattern: TXXXX or TXXXX.YYY +attack_technique_id = st.from_regex(r'^T[0-9]{4}(\.[0-9]{3})?$', fullmatch=True) + +# Strategy for generating lists of technique identifiers (including duplicates) +attack_technique_list = st.lists(attack_technique_id, min_size=0, max_size=20) + +# Strategy for generating valid UUIDs +valid_uuid = st.uuids().map(str) + + +# ============================================================================ +# Property 11: TLP tag format correctness +# ============================================================================ + +class TestTLPTagFormatCorrectness: + """Property 11: TLP tag format correctness. + + **Validates: Requirements 6.1** + + Property Statement: + *For any* valid TLP level, the MISP tag returned by build_tlp_tag() SHALL + match the pattern `tlp:` where is the lowercase TLP level + identifier (clear, green, amber, amber+strict, red). + """ + + # Expected tag mapping for verification + EXPECTED_TAGS = { + TLPLevel.CLEAR: "tlp:clear", + TLPLevel.GREEN: "tlp:green", + TLPLevel.AMBER: "tlp:amber", + TLPLevel.AMBER_STRICT: "tlp:amber+strict", + TLPLevel.RED: "tlp:red", + } + + @given(tlp_level=tlp_levels) + @settings(max_examples=100) + def test_tag_matches_tlp_prefix_pattern(self, tlp_level): + """Test that all TLP tags start with 'tlp:' prefix. + + **Validates: Requirements 6.1** + + This test verifies that for any valid TLP level, the resulting tag + follows the MISP taxonomy format starting with 'tlp:'. + """ + tag = build_tlp_tag(tlp_level) + + # Verify tag has the expected name attribute + assert hasattr(tag, 'name'), ( + f"MISPTag should have a 'name' attribute, got: {type(tag)}" + ) + + # Verify tag name starts with 'tlp:' + assert tag.name.startswith("tlp:"), ( + f"TLP tag for {tlp_level.name} should start with 'tlp:', " + f"got: '{tag.name}'" + ) + + @given(tlp_level=tlp_levels) + @settings(max_examples=100) + def test_tag_matches_exact_format(self, tlp_level): + """Test that TLP tags match the exact expected format. + + **Validates: Requirements 6.1** + + This test verifies that for any valid TLP level, the resulting tag + matches the exact expected MISP taxonomy format. + """ + tag = build_tlp_tag(tlp_level) + expected_tag = self.EXPECTED_TAGS[tlp_level] + + assert tag.name == expected_tag, ( + f"TLP tag for {tlp_level.name} should be '{expected_tag}', " + f"got: '{tag.name}'" + ) + + @given(tlp_level=tlp_levels) + @settings(max_examples=100) + def test_tag_follows_misp_taxonomy_pattern(self, tlp_level): + r"""Test that TLP tags follow the MISP taxonomy pattern. + + **Validates: Requirements 6.1** + + This test verifies that the tag name matches the regex pattern + for valid MISP TLP taxonomy tags: tlp:(clear|green|amber|amber\+strict|red) + """ + tag = build_tlp_tag(tlp_level) + + # MISP TLP taxonomy pattern + tlp_pattern = r'^tlp:(clear|green|amber|amber\+strict|red)$' + + assert re.match(tlp_pattern, tag.name), ( + f"TLP tag '{tag.name}' for {tlp_level.name} does not match " + f"the expected MISP taxonomy pattern '{tlp_pattern}'" + ) + + @given(tlp_level=tlp_levels) + @settings(max_examples=100) + def test_tag_is_lowercase(self, tlp_level): + """Test that TLP tags are in lowercase format. + + **Validates: Requirements 6.1** + + This test verifies that the tag name is entirely lowercase, + as required by the MISP taxonomy format. + """ + tag = build_tlp_tag(tlp_level) + + # The tag name should be lowercase (except for the + in amber+strict) + assert tag.name == tag.name.lower(), ( + f"TLP tag for {tlp_level.name} should be lowercase, " + f"got: '{tag.name}'" + ) + + @given(tlp_level=tlp_levels) + @settings(max_examples=100) + def test_tag_consistency_across_calls(self, tlp_level): + """Test that build_tlp_tag produces consistent results. + + **Validates: Requirements 6.1** + + This test verifies that calling build_tlp_tag multiple times + with the same TLP level always produces the same tag name. + """ + tag1 = build_tlp_tag(tlp_level) + tag2 = build_tlp_tag(tlp_level) + tag3 = build_tlp_tag(tlp_level) + + assert tag1.name == tag2.name == tag3.name, ( + f"build_tlp_tag should produce consistent results for {tlp_level.name}, " + f"got: '{tag1.name}', '{tag2.name}', '{tag3.name}'" + ) + + def test_all_tlp_levels_produce_valid_tags(self): + """Test that all TLP levels produce valid tags. + + **Validates: Requirements 6.1** + + This test exhaustively verifies that every TLP level enum member + produces a valid MISP taxonomy tag. + """ + for tlp_level in TLPLevel: + tag = build_tlp_tag(tlp_level) + expected_tag = self.EXPECTED_TAGS[tlp_level] + + assert tag.name == expected_tag, ( + f"TLP tag for {tlp_level.name} should be '{expected_tag}', " + f"got: '{tag.name}'" + ) + + def test_to_misp_tag_method_consistency(self): + """Test that build_tlp_tag uses TLPLevel.to_misp_tag() correctly. + + **Validates: Requirements 6.1** + + This test verifies that build_tlp_tag produces the same result + as directly calling TLPLevel.to_misp_tag(). + """ + for tlp_level in TLPLevel: + tag = build_tlp_tag(tlp_level) + direct_tag = tlp_level.to_misp_tag() + + assert tag.name == direct_tag, ( + f"build_tlp_tag result should match TLPLevel.to_misp_tag() " + f"for {tlp_level.name}: '{tag.name}' != '{direct_tag}'" + ) + + +# ============================================================================ +# Property 12: ATT&CK technique tag completeness +# ============================================================================ + +class TestATTACKTechniqueTagCompleteness: + """Property 12: ATT&CK technique tag completeness. + + **Validates: Requirements 6.2** + + Property Statement: + *For any* set of resolved ATT&CK technique identifiers returned by + techniques_resolver(), build_attack_tags() SHALL produce exactly one + MISP tag per unique resolved technique identifier. + """ + + @given(techniques=attack_technique_list, object_uuid=valid_uuid) + @settings(max_examples=100) + def test_one_tag_per_unique_technique(self, techniques, object_uuid): + """Test that exactly one tag is created per unique technique. + + **Validates: Requirements 6.2** + + This test verifies that for any list of resolved techniques + (which may contain duplicates), the number of tags produced + equals the number of unique techniques. + """ + unique_techniques = set(techniques) + + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=techniques + ): + tags = build_attack_tags(object_uuid) + + assert len(tags) == len(unique_techniques), ( + f"Expected {len(unique_techniques)} tags for {len(unique_techniques)} " + f"unique techniques from input {techniques}, got {len(tags)} tags" + ) + + @given(techniques=attack_technique_list, object_uuid=valid_uuid) + @settings(max_examples=100) + def test_all_unique_techniques_represented(self, techniques, object_uuid): + """Test that all unique techniques are represented in the tags. + + **Validates: Requirements 6.2** + + This test verifies that every unique technique identifier in the + resolved list has a corresponding tag in the output. + """ + unique_techniques = set(techniques) + + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=techniques + ): + tags = build_attack_tags(object_uuid) + + # Extract technique IDs from tags + tag_technique_ids = set() + for tag in tags: + # Tag format: misp-galaxy:mitre-attack-pattern="TXXXX" + match = re.search(r'"(T[0-9]{4}(?:\.[0-9]{3})?)"', tag.name) + if match: + tag_technique_ids.add(match.group(1)) + + assert tag_technique_ids == unique_techniques, ( + f"Tags should represent all unique techniques. " + f"Expected: {unique_techniques}, Got: {tag_technique_ids}" + ) + + @given(techniques=attack_technique_list, object_uuid=valid_uuid) + @settings(max_examples=100) + def test_no_duplicate_tags(self, techniques, object_uuid): + """Test that no duplicate tags are produced. + + **Validates: Requirements 6.2** + + This test verifies that even when the input contains duplicate + technique identifiers, the output contains no duplicate tags. + """ + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=techniques + ): + tags = build_attack_tags(object_uuid) + + tag_names = [tag.name for tag in tags] + unique_tag_names = set(tag_names) + + assert len(tag_names) == len(unique_tag_names), ( + f"Tags should not contain duplicates. " + f"Got {len(tag_names)} tags but only {len(unique_tag_names)} unique: " + f"{tag_names}" + ) + + @given(techniques=attack_technique_list, object_uuid=valid_uuid) + @settings(max_examples=100) + def test_tags_follow_misp_galaxy_format(self, techniques, object_uuid): + """Test that ATT&CK tags follow the MISP galaxy format. + + **Validates: Requirements 6.2** + + This test verifies that all produced tags follow the expected + MISP galaxy format: misp-galaxy:mitre-attack-pattern="" + """ + # Skip if no techniques (empty list is valid per Requirement 6.6) + assume(len(techniques) > 0) + + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=techniques + ): + tags = build_attack_tags(object_uuid) + + # MISP galaxy ATT&CK pattern format + galaxy_pattern = r'^misp-galaxy:mitre-attack-pattern="T[0-9]{4}(?:\.[0-9]{3})?"$' + + for tag in tags: + assert re.match(galaxy_pattern, tag.name), ( + f"Tag '{tag.name}' does not match expected MISP galaxy format " + f"'{galaxy_pattern}'" + ) + + @given(object_uuid=valid_uuid) + @settings(max_examples=50) + def test_empty_techniques_returns_empty_tags(self, object_uuid): + """Test that empty technique list produces empty tag list. + + **Validates: Requirements 6.2, 6.6** + + This test verifies that when techniques_resolver returns an empty + list, build_attack_tags also returns an empty list (not an error). + """ + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=[] + ): + tags = build_attack_tags(object_uuid) + + assert tags == [], ( + f"Empty technique list should produce empty tag list, got: {tags}" + ) + + @given( + techniques=st.lists(attack_technique_id, min_size=1, max_size=10), + object_uuid=valid_uuid + ) + @settings(max_examples=100) + def test_tag_technique_id_matches_input(self, techniques, object_uuid): + """Test that tag technique IDs exactly match the unique input techniques. + + **Validates: Requirements 6.2** + + This test verifies that the technique IDs embedded in the tags + exactly match the unique input technique identifiers. + """ + unique_techniques = set(techniques) + + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=techniques + ): + tags = build_attack_tags(object_uuid) + + # Extract all technique IDs from tags + extracted_ids = set() + for tag in tags: + # Extract the technique ID from the tag name + # Format: misp-galaxy:mitre-attack-pattern="TXXXX" + start = tag.name.find('"') + 1 + end = tag.name.rfind('"') + if start > 0 and end > start: + extracted_ids.add(tag.name[start:end]) + + assert extracted_ids == unique_techniques, ( + f"Extracted technique IDs should match unique input. " + f"Input: {unique_techniques}, Extracted: {extracted_ids}" + ) + + @given( + techniques=st.lists(attack_technique_id, min_size=1, max_size=5), + object_uuid=valid_uuid + ) + @settings(max_examples=50) + def test_consistency_across_calls(self, techniques, object_uuid): + """Test that build_attack_tags produces consistent results. + + **Validates: Requirements 6.2** + + This test verifies that calling build_attack_tags multiple times + with the same input always produces the same tags (idempotency). + """ + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=techniques + ): + tags1 = build_attack_tags(object_uuid) + tags2 = build_attack_tags(object_uuid) + tags3 = build_attack_tags(object_uuid) + + names1 = sorted([t.name for t in tags1]) + names2 = sorted([t.name for t in tags2]) + names3 = sorted([t.name for t in tags3]) + + assert names1 == names2 == names3, ( + f"build_attack_tags should produce consistent results. " + f"Got: {names1}, {names2}, {names3}" + ) + + def test_techniques_with_subtechniques(self): + """Test handling of both technique and sub-technique IDs. + + **Validates: Requirements 6.2** + + This test verifies that both main techniques (TXXXX) and + sub-techniques (TXXXX.YYY) are handled correctly. + """ + techniques = ["T1003", "T1003.001", "T1003.002", "T1059", "T1059.001"] + + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=techniques + ): + tags = build_attack_tags("test-uuid") + + # Should have one tag per unique technique (5 in this case) + assert len(tags) == 5, ( + f"Expected 5 tags for 5 unique techniques, got {len(tags)}" + ) + + # Verify all IDs are represented + tag_names = [t.name for t in tags] + for tech_id in techniques: + expected_pattern = f'misp-galaxy:mitre-attack-pattern="{tech_id}"' + assert expected_pattern in tag_names, ( + f"Missing tag for technique {tech_id}" + ) + + def test_duplicate_input_produces_deduplicated_output(self): + """Test that duplicate input techniques are deduplicated. + + **Validates: Requirements 6.2** + + This test verifies explicit deduplication behavior - input + with duplicates should produce deduplicated output. + """ + techniques = ["T1003", "T1003", "T1059", "T1003", "T1059"] # 2 unique + + with patch( + "Engines.sharing.tagging.techniques_resolver", + return_value=techniques + ): + tags = build_attack_tags("test-uuid") + + assert len(tags) == 2, ( + f"Expected 2 tags for 2 unique techniques (with duplicates in input), " + f"got {len(tags)}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) + + +# ============================================================================ +# Property 13: Threat actor galaxy attachment priority +# ============================================================================ + +# Strategies for generating actor data +actor_uuid = st.uuids().map(str) +actor_name = st.text( + alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' -_'), + min_size=1, + max_size=50 +).map(str.strip).filter(lambda x: len(x) > 0) +attack_group_id = st.from_regex(r'^G[0-9]{4}$', fullmatch=True) + + +def make_misp_actor(uuid_str: str, name: str) -> dict: + """Helper to create a MISP-stage actor dictionary.""" + return { + "uuid": uuid_str, + "name": name, + "tide": { + "vocab": { + "stages": "misp" + } + } + } + + +def make_attack_actor(name: str, attack_id: str = None) -> dict: + """Helper to create an ATT&CK-stage actor dictionary.""" + actor = { + "name": name, + "tide": { + "vocab": { + "stages": "att&ck" + } + } + } + if attack_id: + actor["id"] = attack_id + return actor + + +def make_other_actor(name: str) -> dict: + """Helper to create an actor with neither misp nor att&ck stage.""" + return { + "name": name, + "tide": { + "vocab": { + "stages": "other" + } + } + } + + +# Strategy for generating MISP-stage actor lists +misp_actor_list = st.lists( + st.tuples(actor_uuid, actor_name).map(lambda t: make_misp_actor(t[0], t[1])), + min_size=0, + max_size=5 +) + +# Strategy for generating ATT&CK-stage actor lists +attack_actor_list = st.lists( + st.tuples(actor_name, attack_group_id).map(lambda t: make_attack_actor(t[0], t[1])), + min_size=0, + max_size=5 +) + + +class TestThreatActorGalaxyPriority: + """Property 13: Threat actor galaxy attachment priority. + + **Validates: Requirements 6.3, 6.4** + + Property Statement: + *For any* TVM object with a threat.actors list: + 1. If actors with tide.vocab.stages == "misp" exist, only misp-stage actors + SHALL be used for galaxy resolution (Req 6.3) + 2. If NO misp-stage actors exist but att&ck-stage actors do, only att&ck-stage + actors SHALL be used for galaxy resolution as fallback (Req 6.4) + 3. Actor galaxies only apply to TVM objects; DOM/MDR return empty (Design spec) + """ + + @given( + misp_actors=st.lists( + st.tuples(actor_uuid, actor_name).map(lambda t: make_misp_actor(t[0], t[1])), + min_size=1, + max_size=5 + ), + attack_actors=st.lists( + st.tuples(actor_name, attack_group_id).map(lambda t: make_attack_actor(t[0], t[1])), + min_size=1, + max_size=5 + ) + ) + @settings(max_examples=100) + def test_misp_actors_take_priority(self, misp_actors, attack_actors): + """Test that misp-stage actors take priority over att&ck-stage actors. + + **Validates: Requirements 6.3, 6.4** + + When both misp-stage and att&ck-stage actors are present, only + misp-stage actors should be processed (att&ck actors are ignored). + """ + # Build object data with both types of actors + object_data = { + "name": "Test TVM Object", + "threat": { + "actors": misp_actors + attack_actors + } + } + + # Mock the MISP client with functions that track which actors are looked up + resolved_misp_uuids = [] + resolved_attack_ids = [] + + def mock_search_galaxy_clusters(galaxy=None, uuid=None, searchall=None, pythonify=False): + if uuid: + resolved_misp_uuids.append(uuid) + # Return a mock cluster to indicate success + return [{ + "GalaxyCluster": { + "uuid": uuid, + "type": "threat-actor", + "value": f"Actor {uuid[:8]}", + "tag_name": f"misp-galaxy:threat-actor=\"Actor {uuid[:8]}\"", + "galaxy_id": "123", + "collection_uuid": "abc" + } + }] + if searchall: + resolved_attack_ids.append(searchall) + return [{ + "GalaxyCluster": { + "uuid": "attack-uuid", + "type": "mitre-intrusion-set", + "value": f"Group {searchall}", + "tag_name": f"misp-galaxy:mitre-intrusion-set=\"{searchall}\"", + "galaxy_id": "456", + "collection_uuid": "def" + } + }] + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("tvm", object_data, mock_client) + + # Verify that only misp-stage actors were looked up (by UUID) + expected_misp_uuids = [a["uuid"] for a in misp_actors] + assert set(resolved_misp_uuids) == set(expected_misp_uuids), ( + f"Only misp-stage actor UUIDs should be looked up. " + f"Expected: {expected_misp_uuids}, Got: {resolved_misp_uuids}" + ) + + # Verify that NO att&ck-stage actors were looked up + assert len(resolved_attack_ids) == 0, ( + f"ATT&CK-stage actors should NOT be looked up when misp-stage actors exist. " + f"But these were looked up: {resolved_attack_ids}" + ) + + # Verify we got results (one per misp actor) + assert len(result) == len(misp_actors), ( + f"Expected {len(misp_actors)} galaxy clusters, got {len(result)}" + ) + + @given( + attack_actors=st.lists( + st.tuples(actor_name, attack_group_id).map(lambda t: make_attack_actor(t[0], t[1])), + min_size=1, + max_size=5 + ) + ) + @settings(max_examples=100) + def test_fallback_to_attack_when_no_misp(self, attack_actors): + """Test fallback to att&ck when no misp-stage actors present. + + **Validates: Requirements 6.3, 6.4** + + When no misp-stage actors exist but att&ck-stage actors do, + the att&ck-stage actors should be used as fallback. + """ + # Build object data with only att&ck actors + object_data = { + "name": "Test TVM Object", + "threat": { + "actors": attack_actors + } + } + + # Track which lookups are performed + resolved_misp_uuids = [] + resolved_attack_ids = [] + + def mock_search_galaxy_clusters(galaxy=None, uuid=None, searchall=None, pythonify=False): + if uuid: + resolved_misp_uuids.append(uuid) + return [] # No results for UUID lookup + if searchall: + resolved_attack_ids.append(searchall) + return [{ + "GalaxyCluster": { + "uuid": "attack-uuid", + "type": "mitre-intrusion-set", + "value": f"Group {searchall}", + "tag_name": f"misp-galaxy:mitre-intrusion-set=\"{searchall}\"", + "galaxy_id": "456", + "collection_uuid": "def" + } + }] + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("tvm", object_data, mock_client) + + # Verify that NO misp-stage lookups occurred (no UUID lookups) + assert len(resolved_misp_uuids) == 0, ( + f"No MISP UUID lookups should occur when there are no misp-stage actors. " + f"But these were looked up: {resolved_misp_uuids}" + ) + + # Verify that att&ck-stage actors were looked up + expected_attack_ids = [a.get("id") for a in attack_actors if a.get("id")] + assert set(resolved_attack_ids) == set(expected_attack_ids), ( + f"ATT&CK-stage actor IDs should be looked up as fallback. " + f"Expected: {expected_attack_ids}, Got: {resolved_attack_ids}" + ) + + # Verify we got results (one per attack actor with ID) + assert len(result) == len([a for a in attack_actors if a.get("id")]), ( + f"Expected one galaxy cluster per ATT&CK actor with ID, " + f"got {len(result)}" + ) + + @given(object_uuid=valid_uuid) + @settings(max_examples=50) + def test_dom_returns_empty(self, object_uuid): + """Test that DOM objects return empty actor galaxies. + + **Validates: Requirements 6.3, 6.4** + + Actor galaxies only apply to TVM objects; DOM should return empty. + """ + # Build a DOM object data with actors (should be ignored) + object_data = { + "name": "Test DOM Object", + "metadata": {"uuid": object_uuid}, + "threat": { + "actors": [ + make_misp_actor("test-uuid", "Test Actor"), + make_attack_actor("Attack Actor", "G0001") + ] + } + } + + # The MISP client should never be called for DOM + lookup_called = [] + + def mock_search_galaxy_clusters(**kwargs): + lookup_called.append(kwargs) + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("dom", object_data, mock_client) + + # Verify empty result for DOM + assert result == [], ( + f"DOM objects should return empty actor galaxies, got: {result}" + ) + + # Verify no MISP lookups occurred + assert len(lookup_called) == 0, ( + f"No MISP lookups should occur for DOM objects, but got: {lookup_called}" + ) + + @given(object_uuid=valid_uuid) + @settings(max_examples=50) + def test_mdr_returns_empty(self, object_uuid): + """Test that MDR objects return empty actor galaxies. + + **Validates: Requirements 6.3, 6.4** + + Actor galaxies only apply to TVM objects; MDR should return empty. + """ + # Build an MDR object data with actors (should be ignored) + object_data = { + "name": "Test MDR Object", + "metadata": {"uuid": object_uuid}, + "threat": { + "actors": [ + make_misp_actor("test-uuid", "Test Actor"), + make_attack_actor("Attack Actor", "G0001") + ] + } + } + + lookup_called = [] + + def mock_search_galaxy_clusters(**kwargs): + lookup_called.append(kwargs) + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("mdr", object_data, mock_client) + + # Verify empty result for MDR + assert result == [], ( + f"MDR objects should return empty actor galaxies, got: {result}" + ) + + # Verify no MISP lookups occurred + assert len(lookup_called) == 0, ( + f"No MISP lookups should occur for MDR objects, but got: {lookup_called}" + ) + + @given( + misp_actors=st.lists( + st.tuples(actor_uuid, actor_name).map(lambda t: make_misp_actor(t[0], t[1])), + min_size=0, + max_size=3 + ), + attack_actors=st.lists( + st.tuples(actor_name, attack_group_id).map(lambda t: make_attack_actor(t[0], t[1])), + min_size=0, + max_size=3 + ), + other_actors=st.lists( + actor_name.map(make_other_actor), + min_size=0, + max_size=3 + ) + ) + @settings(max_examples=100) + def test_priority_with_mixed_stages(self, misp_actors, attack_actors, other_actors): + """Test priority logic with mixed actor stages. + + **Validates: Requirements 6.3, 6.4** + + Verifies the priority: + 1. If any misp-stage actors exist, use only them + 2. Else if any att&ck-stage actors exist, use only them + 3. Else return empty (other stages are ignored) + """ + # Build object data with all types of actors + all_actors = misp_actors + attack_actors + other_actors + object_data = { + "name": "Test TVM Object", + "threat": { + "actors": all_actors + } + } + + # Track lookups + uuid_lookups = [] + searchall_lookups = [] + + def mock_search_galaxy_clusters(galaxy=None, uuid=None, searchall=None, pythonify=False): + if uuid: + uuid_lookups.append(uuid) + return [{ + "GalaxyCluster": { + "uuid": uuid, + "type": "threat-actor", + "value": f"Actor {uuid[:8]}", + "tag_name": f"misp-galaxy:threat-actor=\"Actor\"", + "galaxy_id": "123", + "collection_uuid": "abc" + } + }] + if searchall: + searchall_lookups.append(searchall) + return [{ + "GalaxyCluster": { + "uuid": "attack-uuid", + "type": "mitre-intrusion-set", + "value": f"Group {searchall}", + "tag_name": f"misp-galaxy:mitre-intrusion-set=\"{searchall}\"", + "galaxy_id": "456", + "collection_uuid": "def" + } + }] + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("tvm", object_data, mock_client) + + # Determine expected behavior based on priority + if len(misp_actors) > 0: + # Priority 1: misp-stage actors exist - use only them + expected_uuids = [a["uuid"] for a in misp_actors] + assert set(uuid_lookups) == set(expected_uuids), ( + f"When misp-stage actors exist, only they should be looked up. " + f"Expected UUIDs: {expected_uuids}, Got: {uuid_lookups}" + ) + assert len(searchall_lookups) == 0, ( + f"ATT&CK lookups should not occur when misp-stage actors exist. " + f"Got: {searchall_lookups}" + ) + assert len(result) == len(misp_actors) + elif len(attack_actors) > 0: + # Priority 2: no misp-stage, but att&ck-stage exist - use them + expected_ids = [a.get("id") for a in attack_actors if a.get("id")] + assert len(uuid_lookups) == 0, ( + f"UUID lookups should not occur when using att&ck fallback. " + f"Got: {uuid_lookups}" + ) + assert set(searchall_lookups) == set(expected_ids), ( + f"When falling back to att&ck actors, their IDs should be looked up. " + f"Expected: {expected_ids}, Got: {searchall_lookups}" + ) + # Result count matches attack actors with IDs + assert len(result) == len([a for a in attack_actors if a.get("id")]) + else: + # Priority 3: no misp or att&ck actors - return empty + assert len(uuid_lookups) == 0 + assert len(searchall_lookups) == 0 + assert result == [], ( + f"With no misp or att&ck actors, result should be empty. " + f"Got: {result}" + ) + + @given(object_uuid=valid_uuid) + @settings(max_examples=50) + def test_empty_actors_returns_empty(self, object_uuid): + """Test that empty actor list returns empty result. + + **Validates: Requirements 6.3, 6.4** + """ + object_data = { + "name": "Test TVM Object", + "metadata": {"uuid": object_uuid}, + "threat": { + "actors": [] + } + } + + lookup_called = [] + + def mock_search_galaxy_clusters(**kwargs): + lookup_called.append(kwargs) + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("tvm", object_data, mock_client) + + assert result == [], f"Empty actors list should return empty, got: {result}" + assert len(lookup_called) == 0, "No lookups should occur for empty actors" + + @given(object_uuid=valid_uuid) + @settings(max_examples=50) + def test_missing_threat_section_returns_empty(self, object_uuid): + """Test that missing threat section returns empty result. + + **Validates: Requirements 6.3, 6.4** + """ + object_data = { + "name": "Test TVM Object", + "metadata": {"uuid": object_uuid} + # No threat section + } + + lookup_called = [] + + def mock_search_galaxy_clusters(**kwargs): + lookup_called.append(kwargs) + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("tvm", object_data, mock_client) + + assert result == [], f"Missing threat section should return empty, got: {result}" + assert len(lookup_called) == 0 + + @given(object_uuid=valid_uuid) + @settings(max_examples=50) + def test_missing_actors_key_returns_empty(self, object_uuid): + """Test that missing actors key returns empty result. + + **Validates: Requirements 6.3, 6.4** + """ + object_data = { + "name": "Test TVM Object", + "metadata": {"uuid": object_uuid}, + "threat": { + # No actors key + } + } + + lookup_called = [] + + def mock_search_galaxy_clusters(**kwargs): + lookup_called.append(kwargs) + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("tvm", object_data, mock_client) + + assert result == [], f"Missing actors key should return empty, got: {result}" + assert len(lookup_called) == 0 + + @given( + misp_actors=st.lists( + st.tuples(actor_uuid, actor_name).map(lambda t: make_misp_actor(t[0], t[1])), + min_size=2, + max_size=5 + ) + ) + @settings(max_examples=50) + def test_multiple_misp_actors_all_resolved(self, misp_actors): + """Test that all misp-stage actors are resolved when present. + + **Validates: Requirements 6.3** + """ + object_data = { + "name": "Test TVM Object", + "threat": { + "actors": misp_actors + } + } + + resolved_uuids = [] + + def mock_search_galaxy_clusters(galaxy=None, uuid=None, searchall=None, pythonify=False): + if uuid: + resolved_uuids.append(uuid) + return [{ + "GalaxyCluster": { + "uuid": uuid, + "type": "threat-actor", + "value": f"Actor {uuid[:8]}", + "tag_name": f"misp-galaxy:threat-actor=\"Actor\"", + "galaxy_id": "123", + "collection_uuid": "abc" + } + }] + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("tvm", object_data, mock_client) + + expected_uuids = [a["uuid"] for a in misp_actors] + + # Verify all misp actors were looked up + assert set(resolved_uuids) == set(expected_uuids), ( + f"All misp-stage actor UUIDs should be looked up. " + f"Expected: {expected_uuids}, Got: {resolved_uuids}" + ) + + # Verify result count matches + assert len(result) == len(misp_actors), ( + f"Expected {len(misp_actors)} resolved clusters, got {len(result)}" + ) + + @given( + attack_actors=st.lists( + st.tuples(actor_name, attack_group_id).map(lambda t: make_attack_actor(t[0], t[1])), + min_size=2, + max_size=5 + ) + ) + @settings(max_examples=50) + def test_multiple_attack_actors_all_resolved_as_fallback(self, attack_actors): + """Test that all att&ck-stage actors are resolved when no misp actors. + + **Validates: Requirements 6.4** + """ + object_data = { + "name": "Test TVM Object", + "threat": { + "actors": attack_actors + } + } + + resolved_ids = [] + + def mock_search_galaxy_clusters(galaxy=None, uuid=None, searchall=None, pythonify=False): + if searchall: + resolved_ids.append(searchall) + return [{ + "GalaxyCluster": { + "uuid": "attack-uuid", + "type": "mitre-intrusion-set", + "value": f"Group {searchall}", + "tag_name": f"misp-galaxy:mitre-intrusion-set=\"{searchall}\"", + "galaxy_id": "456", + "collection_uuid": "def" + } + }] + return [] + + mock_client = MagicMock() + mock_client.search_galaxy_clusters = mock_search_galaxy_clusters + + result = build_actor_galaxies("tvm", object_data, mock_client) + + expected_ids = [a.get("id") for a in attack_actors if a.get("id")] + + # Verify all attack actors were looked up + assert set(resolved_ids) == set(expected_ids), ( + f"All att&ck-stage actor IDs should be looked up as fallback. " + f"Expected: {expected_ids}, Got: {resolved_ids}" + ) + + # Verify result count matches actors with IDs + assert len(result) == len([a for a in attack_actors if a.get("id")]), ( + f"Expected {len([a for a in attack_actors if a.get('id')])} resolved clusters, " + f"got {len(result)}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/sharing/test_tlp_properties.py b/tests/sharing/test_tlp_properties.py new file mode 100644 index 00000000..80ff8b6d --- /dev/null +++ b/tests/sharing/test_tlp_properties.py @@ -0,0 +1,488 @@ +"""Property-based tests for TLP hierarchy ordering and scope filtering. + +This module tests Property 4 (TLP hierarchy ordering) and Property 5 +(TLP scope filtering correctness) using Hypothesis. + +**Validates: Requirements 2.1, 2.2, 2.4, 2.6** +""" + +import sys +import uuid +from unittest.mock import patch + +import git +import pytest +from hypothesis import given, settings, assume +from hypothesis import strategies as st + +# Add project root to path for imports +sys.path.insert(0, str(git.Repo(".", search_parent_directories=True).working_dir)) + +from Engines.modules.sharing import TLPLevel, MISPInstanceConfig +from Engines.sharing.scope import compute_sharing_scope, ScopedObject + + +# Strategies for generating test data +tlp_levels = st.sampled_from(list(TLPLevel)) + +tlp_strings = st.sampled_from([ + "clear", "CLEAR", "Clear", + "white", "WHITE", "White", # alias for clear + "green", "GREEN", "Green", + "amber", "AMBER", "Amber", + "amber+strict", "AMBER+STRICT", "Amber+Strict", + "red", "RED", "Red" +]) + +object_types = st.sampled_from(["tvm", "dom", "mdr"]) + + +@st.composite +def valid_uuid(draw): + """Generate a valid UUIDv4 string.""" + return str(uuid.uuid4()) + + +@st.composite +def misp_instance_config(draw, max_tlp=None): + """Generate a valid MISPInstanceConfig for testing. + + Args: + max_tlp: Optional specific TLP level to use. If None, randomly generated. + """ + if max_tlp is None: + max_tlp = draw(tlp_levels) + + return MISPInstanceConfig( + name=draw(st.text(min_size=1, max_size=128, alphabet=st.characters( + whitelist_categories=("Lu", "Ll", "Nd", "Pc"), + whitelist_characters=" -_" + ))), + url=f"https://misp-{draw(st.integers(min_value=1, max_value=1000))}.example.org", + token="test-token", + org_uuid=draw(valid_uuid()), + max_allowed_tlp=max_tlp, + mode=draw(st.sampled_from(["send", "fetch", "sync"])), + proxy=draw(st.booleans()), + publish_on_change=draw(st.booleans()), + verify_ssl=draw(st.booleans()) + ) + + +@st.composite +def opentide_object(draw, tlp_level=None): + """Generate a valid OpenTIDE object dictionary. + + Args: + tlp_level: Optional specific TLP level string. If None, randomly generated. + """ + if tlp_level is None: + tlp_level = draw(tlp_strings) + + obj_uuid = draw(valid_uuid()) + obj_name = draw(st.text(min_size=1, max_size=100, alphabet=st.characters( + whitelist_categories=("Lu", "Ll", "Nd", "Pc"), + whitelist_characters=" -_" + ))) + + return { + "uuid": obj_uuid, + "name": obj_name, + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_level, + "version": draw(st.integers(min_value=1, max_value=100)) + } + } + + +@st.composite +def opentide_object_with_specific_tlp(draw, tlp_level: TLPLevel): + """Generate an OpenTIDE object with a specific TLP level.""" + tlp_string_mapping = { + TLPLevel.CLEAR: draw(st.sampled_from(["clear", "white", "CLEAR", "WHITE"])), + TLPLevel.GREEN: draw(st.sampled_from(["green", "GREEN"])), + TLPLevel.AMBER: draw(st.sampled_from(["amber", "AMBER"])), + TLPLevel.AMBER_STRICT: draw(st.sampled_from(["amber+strict", "AMBER+STRICT"])), + TLPLevel.RED: draw(st.sampled_from(["red", "RED"])) + } + return draw(opentide_object(tlp_level=tlp_string_mapping[tlp_level])) + + +# ============================================================================ +# Property 5: TLP scope filtering correctness +# ============================================================================ + +class TestTLPScopeFilteringCorrectness: + """Property 5: TLP scope filtering correctness. + + **Validates: Requirements 2.2, 2.4** + + Property Statement: + *For any* OpenTIDE object with a valid TLP value and *for any* MISP instance + configuration with a valid `max_allowed_tlp`, the object SHALL be included + in the sharing scope if and only if the object's TLP level is less than or + equal to the instance's `max_allowed_tlp` level according to the TLP hierarchy. + """ + + @given( + object_tlp=tlp_levels, + instance_max_tlp=tlp_levels, + object_type=object_types + ) + @settings(max_examples=100) + def test_object_included_iff_tlp_within_limit( + self, object_tlp, instance_max_tlp, object_type + ): + """Test that an object is included iff its TLP <= instance max_allowed_tlp. + + **Validates: Requirements 2.2, 2.4** + + This test verifies the bi-conditional relationship: + - object TLP <= max_allowed_tlp → object IS in scope + - object TLP > max_allowed_tlp → object is NOT in scope + """ + # Generate test data + obj_uuid = str(uuid.uuid4()) + + # Map TLP level to a string representation + tlp_string_map = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red" + } + + object_data = { + "uuid": obj_uuid, + "name": f"Test Object {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_string_map[object_tlp], + "version": 1 + } + } + + instance_config = MISPInstanceConfig( + name="Test MISP Instance", + url="https://misp.example.org", + token="test-token", + org_uuid=str(uuid.uuid4()), + max_allowed_tlp=instance_max_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True + ) + + all_objects = {obj_uuid: (object_type, object_data)} + + # Mock the log function to avoid output during tests + with patch("Engines.sharing.scope.log"): + scope = compute_sharing_scope(instance_config, all_objects) + + # Extract UUIDs from scope + scope_uuids = {obj.uuid for obj in scope} + + # Verify the bi-conditional property + expected_in_scope = object_tlp <= instance_max_tlp + actually_in_scope = obj_uuid in scope_uuids + + assert expected_in_scope == actually_in_scope, ( + f"TLP scope filtering mismatch: " + f"object TLP={object_tlp.name} ({object_tlp.value}), " + f"instance max_tlp={instance_max_tlp.name} ({instance_max_tlp.value}), " + f"expected in scope={expected_in_scope}, " + f"actually in scope={actually_in_scope}" + ) + + @given( + object_tlp=tlp_levels, + object_type=object_types + ) + @settings(max_examples=100) + def test_object_always_included_when_tlp_at_or_below_max( + self, object_tlp, object_type + ): + """Test that objects are always included when their TLP is at or below max. + + **Validates: Requirements 2.2** + + For any TLP level, if we set max_allowed_tlp to RED (the maximum), + all objects should be included. + """ + obj_uuid = str(uuid.uuid4()) + + tlp_string_map = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red" + } + + object_data = { + "uuid": obj_uuid, + "name": f"Test Object {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_string_map[object_tlp], + "version": 1 + } + } + + # Max allowed TLP is RED (highest), so all objects should be included + instance_config = MISPInstanceConfig( + name="Test MISP Instance", + url="https://misp.example.org", + token="test-token", + org_uuid=str(uuid.uuid4()), + max_allowed_tlp=TLPLevel.RED, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True + ) + + all_objects = {obj_uuid: (object_type, object_data)} + + with patch("Engines.sharing.scope.log"): + scope = compute_sharing_scope(instance_config, all_objects) + + scope_uuids = {obj.uuid for obj in scope} + + assert obj_uuid in scope_uuids, ( + f"Object with TLP {object_tlp.name} should be included when " + f"max_allowed_tlp is RED, but was excluded" + ) + + @given( + object_tlp=st.sampled_from([TLPLevel.GREEN, TLPLevel.AMBER, TLPLevel.AMBER_STRICT, TLPLevel.RED]), + object_type=object_types + ) + @settings(max_examples=100) + def test_object_excluded_when_tlp_above_max( + self, object_tlp, object_type + ): + """Test that objects are excluded when their TLP exceeds max_allowed_tlp. + + **Validates: Requirements 2.2, 2.4** + + For any TLP level above CLEAR, if we set max_allowed_tlp to a lower level, + the object should be excluded. + """ + # Ensure object_tlp > CLEAR so we can set a lower max + assume(object_tlp > TLPLevel.CLEAR) + + # Set max_allowed_tlp to one level below the object's TLP + max_tlp_level = TLPLevel(object_tlp - 1) + + obj_uuid = str(uuid.uuid4()) + + tlp_string_map = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red" + } + + object_data = { + "uuid": obj_uuid, + "name": f"Test Object {obj_uuid[:8]}", + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_string_map[object_tlp], + "version": 1 + } + } + + instance_config = MISPInstanceConfig( + name="Test MISP Instance", + url="https://misp.example.org", + token="test-token", + org_uuid=str(uuid.uuid4()), + max_allowed_tlp=max_tlp_level, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True + ) + + all_objects = {obj_uuid: (object_type, object_data)} + + with patch("Engines.sharing.scope.log"): + scope = compute_sharing_scope(instance_config, all_objects) + + scope_uuids = {obj.uuid for obj in scope} + + assert obj_uuid not in scope_uuids, ( + f"Object with TLP {object_tlp.name} should be excluded when " + f"max_allowed_tlp is {max_tlp_level.name}, but was included" + ) + + @given( + object_types_list=st.lists(object_types, min_size=1, max_size=10), + instance_max_tlp=tlp_levels + ) + @settings(max_examples=100) + def test_independent_filtering_per_instance( + self, object_types_list, instance_max_tlp + ): + """Test that TLP filtering is applied independently for each MISP instance. + + **Validates: Requirements 2.4** + + Different instances with different max_allowed_tlp values should filter + the same set of objects differently. + """ + # Generate objects with varying TLP levels + all_objects = {} + tlp_levels_list = list(TLPLevel) + + tlp_string_map = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red" + } + + for i, obj_type in enumerate(object_types_list): + obj_uuid = str(uuid.uuid4()) + obj_tlp = tlp_levels_list[i % len(tlp_levels_list)] + + object_data = { + "uuid": obj_uuid, + "name": f"Test Object {i}", + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_string_map[obj_tlp], + "version": 1 + } + } + all_objects[obj_uuid] = (obj_type, object_data) + + # Create instance config with the given max TLP + instance_config = MISPInstanceConfig( + name="Test MISP Instance", + url="https://misp.example.org", + token="test-token", + org_uuid=str(uuid.uuid4()), + max_allowed_tlp=instance_max_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True + ) + + with patch("Engines.sharing.scope.log"): + scope = compute_sharing_scope(instance_config, all_objects) + + # Verify each object in scope has TLP <= max_allowed_tlp + for scoped_obj in scope: + assert scoped_obj.tlp <= instance_max_tlp, ( + f"Object {scoped_obj.uuid} with TLP {scoped_obj.tlp.name} " + f"should not be in scope for instance with " + f"max_allowed_tlp={instance_max_tlp.name}" + ) + + # Verify all objects with TLP <= max_allowed_tlp are in scope + scope_uuids = {obj.uuid for obj in scope} + for obj_uuid, (_, obj_data) in all_objects.items(): + obj_tlp_str = obj_data["metadata"]["tlp"] + obj_tlp = TLPLevel.from_string(obj_tlp_str) + + if obj_tlp <= instance_max_tlp: + assert obj_uuid in scope_uuids, ( + f"Object {obj_uuid} with TLP {obj_tlp.name} should be in scope " + f"for instance with max_allowed_tlp={instance_max_tlp.name}" + ) + + @given(object_type=object_types) + @settings(max_examples=50) + def test_all_tlp_boundary_conditions(self, object_type): + """Test boundary conditions at each TLP level. + + **Validates: Requirements 2.2, 2.4** + + For each TLP level, verify that: + - An object at that exact level is included when max_allowed_tlp equals it + - An object at that exact level is excluded when max_allowed_tlp is below it + """ + tlp_string_map = { + TLPLevel.CLEAR: "clear", + TLPLevel.GREEN: "green", + TLPLevel.AMBER: "amber", + TLPLevel.AMBER_STRICT: "amber+strict", + TLPLevel.RED: "red" + } + + for object_tlp in TLPLevel: + obj_uuid = str(uuid.uuid4()) + + object_data = { + "uuid": obj_uuid, + "name": f"Test Object {object_tlp.name}", + "metadata": { + "uuid": obj_uuid, + "tlp": tlp_string_map[object_tlp], + "version": 1 + } + } + + all_objects = {obj_uuid: (object_type, object_data)} + + # Test exact boundary: max_allowed_tlp == object TLP + instance_config = MISPInstanceConfig( + name="Test MISP Instance", + url="https://misp.example.org", + token="test-token", + org_uuid=str(uuid.uuid4()), + max_allowed_tlp=object_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True + ) + + with patch("Engines.sharing.scope.log"): + scope = compute_sharing_scope(instance_config, all_objects) + + scope_uuids = {obj.uuid for obj in scope} + + assert obj_uuid in scope_uuids, ( + f"Object with TLP {object_tlp.name} should be included when " + f"max_allowed_tlp equals {object_tlp.name}" + ) + + # Test below boundary: max_allowed_tlp < object TLP + if object_tlp > TLPLevel.CLEAR: + below_tlp = TLPLevel(object_tlp - 1) + + instance_config_below = MISPInstanceConfig( + name="Test MISP Instance Below", + url="https://misp.example.org", + token="test-token", + org_uuid=str(uuid.uuid4()), + max_allowed_tlp=below_tlp, + mode="send", + proxy=False, + publish_on_change=False, + verify_ssl=True + ) + + with patch("Engines.sharing.scope.log"): + scope_below = compute_sharing_scope(instance_config_below, all_objects) + + scope_below_uuids = {obj.uuid for obj in scope_below} + + assert obj_uuid not in scope_below_uuids, ( + f"Object with TLP {object_tlp.name} should be excluded when " + f"max_allowed_tlp is {below_tlp.name}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 1320db517b84ef1bab8d764ec861febd9ec345d3 Mon Sep 17 00:00:00 2001 From: "SEGUY Remi (DIGIT)" Date: Thu, 25 Jun 2026 09:13:48 +0200 Subject: [PATCH 2/2] refactor: Move sharing tests to tests/sharing directory --- Engines/sharing/tests/__init__.py | 1 - .../tests => tests/sharing}/test_config_parsing_properties.py | 0 {Engines/sharing/tests => tests/sharing}/test_connector.py | 0 {Engines/sharing/tests => tests/sharing}/test_events.py | 0 .../sharing/tests => tests/sharing}/test_events_lifecycle.py | 0 .../sharing}/test_misp_object_completeness_property.py | 0 .../sharing/tests => tests/sharing}/test_relations_property.py | 0 .../tests => tests/sharing}/test_tlp_hierarchy_property.py | 0 .../tests => tests/sharing}/test_tlp_scope_filtering_property.py | 0 .../tests => tests/sharing}/test_tvm_chaining_property.py | 0 {Engines/sharing/tests => tests/sharing}/test_uuid_derivation.py | 0 .../tests => tests/sharing}/test_version_comparison_property.py | 0 12 files changed, 1 deletion(-) delete mode 100644 Engines/sharing/tests/__init__.py rename {Engines/sharing/tests => tests/sharing}/test_config_parsing_properties.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_connector.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_events.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_events_lifecycle.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_misp_object_completeness_property.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_relations_property.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_tlp_hierarchy_property.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_tlp_scope_filtering_property.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_tvm_chaining_property.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_uuid_derivation.py (100%) rename {Engines/sharing/tests => tests/sharing}/test_version_comparison_property.py (100%) diff --git a/Engines/sharing/tests/__init__.py b/Engines/sharing/tests/__init__.py deleted file mode 100644 index c02237b0..00000000 --- a/Engines/sharing/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Sharing module tests package diff --git a/Engines/sharing/tests/test_config_parsing_properties.py b/tests/sharing/test_config_parsing_properties.py similarity index 100% rename from Engines/sharing/tests/test_config_parsing_properties.py rename to tests/sharing/test_config_parsing_properties.py diff --git a/Engines/sharing/tests/test_connector.py b/tests/sharing/test_connector.py similarity index 100% rename from Engines/sharing/tests/test_connector.py rename to tests/sharing/test_connector.py diff --git a/Engines/sharing/tests/test_events.py b/tests/sharing/test_events.py similarity index 100% rename from Engines/sharing/tests/test_events.py rename to tests/sharing/test_events.py diff --git a/Engines/sharing/tests/test_events_lifecycle.py b/tests/sharing/test_events_lifecycle.py similarity index 100% rename from Engines/sharing/tests/test_events_lifecycle.py rename to tests/sharing/test_events_lifecycle.py diff --git a/Engines/sharing/tests/test_misp_object_completeness_property.py b/tests/sharing/test_misp_object_completeness_property.py similarity index 100% rename from Engines/sharing/tests/test_misp_object_completeness_property.py rename to tests/sharing/test_misp_object_completeness_property.py diff --git a/Engines/sharing/tests/test_relations_property.py b/tests/sharing/test_relations_property.py similarity index 100% rename from Engines/sharing/tests/test_relations_property.py rename to tests/sharing/test_relations_property.py diff --git a/Engines/sharing/tests/test_tlp_hierarchy_property.py b/tests/sharing/test_tlp_hierarchy_property.py similarity index 100% rename from Engines/sharing/tests/test_tlp_hierarchy_property.py rename to tests/sharing/test_tlp_hierarchy_property.py diff --git a/Engines/sharing/tests/test_tlp_scope_filtering_property.py b/tests/sharing/test_tlp_scope_filtering_property.py similarity index 100% rename from Engines/sharing/tests/test_tlp_scope_filtering_property.py rename to tests/sharing/test_tlp_scope_filtering_property.py diff --git a/Engines/sharing/tests/test_tvm_chaining_property.py b/tests/sharing/test_tvm_chaining_property.py similarity index 100% rename from Engines/sharing/tests/test_tvm_chaining_property.py rename to tests/sharing/test_tvm_chaining_property.py diff --git a/Engines/sharing/tests/test_uuid_derivation.py b/tests/sharing/test_uuid_derivation.py similarity index 100% rename from Engines/sharing/tests/test_uuid_derivation.py rename to tests/sharing/test_uuid_derivation.py diff --git a/Engines/sharing/tests/test_version_comparison_property.py b/tests/sharing/test_version_comparison_property.py similarity index 100% rename from Engines/sharing/tests/test_version_comparison_property.py rename to tests/sharing/test_version_comparison_property.py