From 827a815c95bcef9a8aa9c34ab19c76d9afa4cf59 Mon Sep 17 00:00:00 2001 From: Khidr6g <270672697+Khidr6G@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:54:43 +0300 Subject: [PATCH 1/8] feat(metras): add Metras internal-enrichment connector for fleet-presence lookups (#7038) --- internal-enrichment/metras/.dockerignore | 11 + internal-enrichment/metras/Dockerfile | 22 ++ internal-enrichment/metras/README.md | 58 ++++ internal-enrichment/metras/VERSION | 1 + .../__metadata__/connector_manifest.json | 19 ++ internal-enrichment/metras/config.yml.sample | 17 ++ internal-enrichment/metras/docker-compose.yml | 29 ++ internal-enrichment/metras/entrypoint.sh | 3 + internal-enrichment/metras/pyproject.toml | 5 + .../metras/src/connector/__init__.py | 4 + .../metras/src/connector/connector.py | 268 ++++++++++++++++++ .../metras/src/connector/converter_to_stix.py | 82 ++++++ .../metras/src/connector/settings.py | 52 ++++ .../metras/src/connector/utils.py | 31 ++ internal-enrichment/metras/src/main.py | 17 ++ .../metras/src/metras_client/__init__.py | 3 + .../metras/src/metras_client/api_client.py | 258 +++++++++++++++++ .../metras/src/requirements.txt | 6 + internal-enrichment/metras/tests/conftest.py | 4 + .../metras/tests/test-requirements.txt | 2 + .../tests/test_connector/test_client.py | 72 +++++ .../test_connector/test_orchestration.py | 88 ++++++ .../tests/test_connector/test_settings.py | 27 ++ internal-enrichment/metras/tests/test_main.py | 13 + 24 files changed, 1092 insertions(+) create mode 100644 internal-enrichment/metras/.dockerignore create mode 100644 internal-enrichment/metras/Dockerfile create mode 100644 internal-enrichment/metras/README.md create mode 100644 internal-enrichment/metras/VERSION create mode 100644 internal-enrichment/metras/__metadata__/connector_manifest.json create mode 100644 internal-enrichment/metras/config.yml.sample create mode 100644 internal-enrichment/metras/docker-compose.yml create mode 100644 internal-enrichment/metras/entrypoint.sh create mode 100644 internal-enrichment/metras/pyproject.toml create mode 100644 internal-enrichment/metras/src/connector/__init__.py create mode 100644 internal-enrichment/metras/src/connector/connector.py create mode 100644 internal-enrichment/metras/src/connector/converter_to_stix.py create mode 100644 internal-enrichment/metras/src/connector/settings.py create mode 100644 internal-enrichment/metras/src/connector/utils.py create mode 100644 internal-enrichment/metras/src/main.py create mode 100644 internal-enrichment/metras/src/metras_client/__init__.py create mode 100644 internal-enrichment/metras/src/metras_client/api_client.py create mode 100644 internal-enrichment/metras/src/requirements.txt create mode 100644 internal-enrichment/metras/tests/conftest.py create mode 100644 internal-enrichment/metras/tests/test-requirements.txt create mode 100644 internal-enrichment/metras/tests/test_connector/test_client.py create mode 100644 internal-enrichment/metras/tests/test_connector/test_orchestration.py create mode 100644 internal-enrichment/metras/tests/test_connector/test_settings.py create mode 100644 internal-enrichment/metras/tests/test_main.py diff --git a/internal-enrichment/metras/.dockerignore b/internal-enrichment/metras/.dockerignore new file mode 100644 index 00000000000..33cdfff0093 --- /dev/null +++ b/internal-enrichment/metras/.dockerignore @@ -0,0 +1,11 @@ +__metadata__ +**/__pycache__ +**/__docs__ +**/.venv +**/venv +**/logs +**/config.yml +**/*.egg-info +**/*.gql +.plan +tests diff --git a/internal-enrichment/metras/Dockerfile b/internal-enrichment/metras/Dockerfile new file mode 100644 index 00000000000..936756655a8 --- /dev/null +++ b/internal-enrichment/metras/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-alpine + +ENV CONNECTOR_TYPE=INTERNAL_ENRICHMENT +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY src /opt/opencti-connector-metras-enrichment + +RUN apk update && apk upgrade --no-cache && \ + apk --no-cache add libmagic libffi libxml2 libxslt && \ + apk --no-cache add --virtual .build-deps git build-base libffi-dev libxml2-dev libxslt-dev && \ + cd /opt/opencti-connector-metras-enrichment && \ + pip3 install --no-cache-dir -r requirements.txt && \ + apk del .build-deps + +COPY entrypoint.sh / +RUN chmod +x /entrypoint.sh && \ + adduser -D -u 1000 connector && \ + chown -R connector:connector /opt/opencti-connector-metras-enrichment +USER connector + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/internal-enrichment/metras/README.md b/internal-enrichment/metras/README.md new file mode 100644 index 00000000000..5c1aa1177c5 --- /dev/null +++ b/internal-enrichment/metras/README.md @@ -0,0 +1,58 @@ +# OpenCTI Metras Enrichment Connector (INTERNAL_ENRICHMENT) + +Answers *"have I seen this in my fleet?"* by querying the [Metras](https://dashboard.metras.sa/) +platform when you enrich an observable in OpenCTI. Adds a context **Note** (and +**System identity** links to matched fleet endpoints) summarising local EDR/endpoint presence. + +## Supported observables (`CONNECTOR_SCOPE`) + +| Observable | Metras lookups | Output | +|---|---|---| +| **IPv4-Addr** | `/v1/edr/alerts?agent_ip=`, `/v1/endpoints?interface_ip=` | Note (alert + endpoint hits) + `related-to` **System identity** per matched endpoint | +| **StixFile** | `/v1/edr/binary/list?query=sha256:` / `?query=sha1:`, `/v1/edr/binary/details?md5=` | Note (publisher, signer, signature/runnability status, first/last seen) + System identity | + +> Matched fleet endpoints are emitted as `Identity(identity_class="system")` (internal assets, +> not IOCs), consistent with the Feed connector and OpenCTI maintainer guidance (PR #6164). + +> **Why only IPv4 + StixFile?** Metras exposes no value-lookup for domains or URLs, and +> `/v4/threats/detail` requires incident identifiers (title+direction+type), not an observable +> value. Domain/URL enrichment is therefore not offered. +> +> Fleet presence is conveyed via **Notes + relationships**, not STIX Sightings (the `stix2` +> library forbids a Sighting referencing an observable). + +## Behavior & safety +- **Custom TLP check** (not `helper.check_max_tlp`) — skips observables above `METRAS_MAX_TLP`. +- **Refangs** values before querying Metras. +- **Partial results**: each lookup is wrapped; if at least one succeeds, results are sent. + If *all* lookups fail, a `ValueError` is raised so the failure is visible in the OpenCTI UI. +- Playbook-compatible (`playbook_compatible=True`, `entity_in_scope()` guard). + +## Requirements +- OpenCTI **7.260529.0** (`pycti==7.260529.0`). A Metras API key. + +## Configuration + +| Env var | Required | Default | Description | +|---|---|---|---| +| `OPENCTI_URL` / `OPENCTI_TOKEN` / `CONNECTOR_ID` | yes | — | Standard connector settings | +| `CONNECTOR_NAME` | no | `Metras-Enrichment` | Connector name | +| `CONNECTOR_SCOPE` | no | `IPv4-Addr,StixFile` | Observable types to enrich | +| `CONNECTOR_AUTO` | no | `false` | Auto-enrich on observable creation | +| `CONNECTOR_LOG_LEVEL` | no | `info` | Log level | +| `METRAS_API_BASE_URL` | no | `https://api.metras.sa/api` | Metras API base URL | +| `METRAS_API_KEY` | yes | — | Metras API key | +| `METRAS_VERIFY_SSL` | no | `true` | Verify TLS certificates | +| `METRAS_MAX_TLP` | no | `amber+strict` | Max TLP to enrich (`clear`/`white`/`green`/`amber`/`amber+strict`/`red`) | + +## Usage +Right-click an IPv4 or file-hash observable → **Enrich** → *Metras-Enrichment*, or set +`CONNECTOR_AUTO=true` to enrich automatically. Triggering via API uses +`stixCoreObjectEdit.askEnrichment(connectorId)` on OpenCTI 7.260529.0+. + +## Troubleshooting +| Symptom | Cause / fix | +|---|---| +| "No Metras fleet data found" | The observable isn't present in your Metras fleet (expected for unknown IOCs) | +| Enrichment fails with auth error | Bad `METRAS_API_KEY` | +| File observable not enriched | Ensure `StixFile` is in `CONNECTOR_SCOPE` | diff --git a/internal-enrichment/metras/VERSION b/internal-enrichment/metras/VERSION new file mode 100644 index 00000000000..3eefcb9dd5b --- /dev/null +++ b/internal-enrichment/metras/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/internal-enrichment/metras/__metadata__/connector_manifest.json b/internal-enrichment/metras/__metadata__/connector_manifest.json new file mode 100644 index 00000000000..4fb5ecf83c6 --- /dev/null +++ b/internal-enrichment/metras/__metadata__/connector_manifest.json @@ -0,0 +1,19 @@ +{ + "title": "Metras Enrichment Connector", + "slug": "metras-enrichment", + "description": "Enriches IPv4 and file-hash observables by querying Metras for fleet presence (EDR alerts, endpoint inventory, binary inventory), returning context Notes and system-identity links ('have I seen this in my environment?').", + "short_description": "Enrichment connector for Metras fleet sightings", + "logo": null, + "use_cases": ["Enrichment & Analysis"], + "verified": false, + "last_verified_date": null, + "playbook_supported": true, + "max_confidence_level": 50, + "support_version": ">=7.260529.0", + "subscription_link": null, + "source_code": null, + "manager_supported": false, + "container_version": "1.0.0", + "container_image": "opencti/connector-metras-enrichment", + "container_type": "INTERNAL_ENRICHMENT" +} diff --git a/internal-enrichment/metras/config.yml.sample b/internal-enrichment/metras/config.yml.sample new file mode 100644 index 00000000000..21d8b1518a6 --- /dev/null +++ b/internal-enrichment/metras/config.yml.sample @@ -0,0 +1,17 @@ +opencti: + url: 'http://localhost:8080' + token: 'ChangeMe' + +connector: + type: 'INTERNAL_ENRICHMENT' + id: 'ChangeMe' # UUIDv4 + name: 'Metras-Enrichment' + scope: 'IPv4-Addr,StixFile' + log_level: 'info' + auto: false + +metras: + api_base_url: 'https://api.metras.sa/api' + api_key: 'ChangeMe' + verify_ssl: true + max_tlp: 'amber+strict' diff --git a/internal-enrichment/metras/docker-compose.yml b/internal-enrichment/metras/docker-compose.yml new file mode 100644 index 00000000000..f8076a2663b --- /dev/null +++ b/internal-enrichment/metras/docker-compose.yml @@ -0,0 +1,29 @@ +services: + connector-metras-enrichment: + build: + context: . + dockerfile: Dockerfile + image: opencti/connector-metras-enrichment:1.0.0 + environment: + - OPENCTI_URL=http://opencti:8080 + - OPENCTI_TOKEN=${OPENCTI_ADMIN_TOKEN} + - CONNECTOR_ID=${CONNECTOR_METRAS_ENRICHMENT_ID} + - CONNECTOR_TYPE=INTERNAL_ENRICHMENT + - CONNECTOR_NAME=Metras-Enrichment + - CONNECTOR_SCOPE=IPv4-Addr,StixFile + - CONNECTOR_LOG_LEVEL=info + - CONNECTOR_AUTO=false + - METRAS_API_BASE_URL=https://api.metras.sa/api + - METRAS_API_KEY=${METRAS_API_KEY} + - METRAS_VERIFY_SSL=true + - METRAS_MAX_TLP=amber+strict + restart: always + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + mem_limit: 512m + pids_limit: 100 diff --git a/internal-enrichment/metras/entrypoint.sh b/internal-enrichment/metras/entrypoint.sh new file mode 100644 index 00000000000..db0792b72fa --- /dev/null +++ b/internal-enrichment/metras/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd /opt/opencti-connector-metras-enrichment +exec python3 main.py diff --git a/internal-enrichment/metras/pyproject.toml b/internal-enrichment/metras/pyproject.toml new file mode 100644 index 00000000000..1a5498cf328 --- /dev/null +++ b/internal-enrichment/metras/pyproject.toml @@ -0,0 +1,5 @@ +[tool.coverage.run] +source = ["connector", "metras_client"] + +[tool.coverage.report] +show_missing = true diff --git a/internal-enrichment/metras/src/connector/__init__.py b/internal-enrichment/metras/src/connector/__init__.py new file mode 100644 index 00000000000..5b66c8191ff --- /dev/null +++ b/internal-enrichment/metras/src/connector/__init__.py @@ -0,0 +1,4 @@ +from connector.connector import MetrasEnrichmentConnector +from connector.settings import ConnectorSettings + +__all__ = ["MetrasEnrichmentConnector", "ConnectorSettings"] diff --git a/internal-enrichment/metras/src/connector/connector.py b/internal-enrichment/metras/src/connector/connector.py new file mode 100644 index 00000000000..ebc27e7db63 --- /dev/null +++ b/internal-enrichment/metras/src/connector/connector.py @@ -0,0 +1,268 @@ +"""Metras Enrichment connector (INTERNAL_ENRICHMENT). + +Answers "have I seen this in my fleet?" for IPv4-Addr / file-hash observables by +querying Metras, returning context Notes + System-identity links. +""" + +from collections.abc import Callable +from typing import Any + +from connector.converter_to_stix import ConverterToStix +from connector.settings import ConnectorSettings +from connector.utils import refang +from metras_client import MetrasAPIError, MetrasClient +from pycti import OpenCTIConnectorHelper + + +class MetrasEnrichmentConnector: + + # TLP levels by restrictiveness (keyed on connectors_sdk TLPLevel values). + _LEVEL_ORDER = { + "clear": 0, + "white": 0, + "green": 1, + "amber": 2, + "amber+strict": 3, + "red": 4, + } + _MARKING_TO_LEVEL = { + "marking-definition--613f2e26-407d-48c7-9eba-b56c171b6f0c": "white", + "marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da": "green", + "marking-definition--f88d31f6-486f-44da-b317-01333bde0b82": "amber", + "marking-definition--826578e1-40ad-459f-bc73-ede076f81f37": "amber+strict", + "marking-definition--5e57c739-391a-4eb3-b6be-7d15ca92d5ed": "red", + } + # OpenCTI scope names vs STIX SCO type names that differ. + _SCOPE_ALIASES = {"file": "stixfile"} + + def __init__( + self, config: ConnectorSettings, helper: OpenCTIConnectorHelper + ) -> None: + self.config = config + self.helper = helper + cfg = config.metras + self.client = MetrasClient( + helper=helper, + base_url=str(cfg.api_base_url), + api_key=cfg.api_key.get_secret_value(), + verify_ssl=cfg.verify_ssl, + ) + self.converter = ConverterToStix(helper) + self._max_tlp = cfg.max_tlp # connectors_sdk TLPLevel enum + self._errors = [] + self._successes = 0 + + # ------------------------------------------------------------------ # + def run(self) -> None: + try: + self.client.ping() + self.helper.connector_logger.info( + "[CONNECTOR] Metras API connection verified" + ) + except MetrasAPIError as exc: + self.helper.connector_logger.error( + "[CONNECTOR] Metras API ping failed at startup", {"error": str(exc)} + ) + # Listener still starts so the connector registers; per-message calls + # will surface auth errors clearly to the analyst. + self.helper.listen(message_callback=self.process_message) + + # --- Scope + TLP helpers (custom — never helper.check_max_tlp) --- + def entity_in_scope(self, stix_type: str) -> bool: + scopes = [s.lower() for s in self.config.connector.scope] + entity_type = self._SCOPE_ALIASES.get(stix_type.lower(), stix_type.lower()) + return entity_type in scopes + + def _get_tlp_level(self, stix_entity) -> str | None: + """Return the most restrictive TLP level (lowercase) on the entity, or None.""" + markings = (stix_entity or {}).get("object_marking_refs") or [] + highest, result = -1, None + for m in markings: + mid = m if isinstance(m, str) else m.get("standard_id", m.get("id", "")) + level = self._MARKING_TO_LEVEL.get(mid) + if level and self._LEVEL_ORDER.get(level, -1) > highest: + highest = self._LEVEL_ORDER[level] + result = level + return result + + def _tlp_allowed(self, level: str) -> bool: + max_level = self._max_tlp.value # TLPLevel enum -> lowercase value + return self._LEVEL_ORDER.get(level, 0) <= self._LEVEL_ORDER.get(max_level, 4) + + def _safe(self, func: Callable, *args, **kwargs) -> Any: + try: + result = func(*args, **kwargs) + self._successes += 1 + return result + except Exception as exc: # noqa: BLE001 + self.helper.connector_logger.error( + f"[CONNECTOR] {getattr(func, '__name__', 'call')} failed", + {"error": str(exc)}, + ) + self._errors.append(str(exc)) + return None + + # ------------------------------------------------------------------ # + @staticmethod + def _resolve_stix_id(data, stix_entity) -> str | None: + """Resolve the enriched entity's STIX id: standard_id, then the stix_entity id. + + Never the internal OpenCTI database id (not a valid STIX reference — would + produce inconsistent object_refs/relationships). + """ + enrichment_entity = data.get("enrichment_entity") or {} + return enrichment_entity.get("standard_id") or stix_entity.get("id") + + def process_message(self, data: dict) -> str | None: + try: + stix_entity = data.get("stix_entity") or {} + stix_objects = data.get("stix_objects", []) + obs_type = (stix_entity.get("type") or "").lower() + obs_id = self._resolve_stix_id(data, stix_entity) + if not obs_id: + raise ValueError( + "[CONNECTOR] Could not resolve a STIX id for the entity " + "(no standard_id or stix_entity id); aborting." + ) + + level = self._get_tlp_level(stix_entity) + if level and not self._tlp_allowed(level): + self.helper.connector_logger.warning( + "[CONNECTOR] Skipped: TLP exceeds max", + {"tlp": level, "max": self._max_tlp.value}, + ) + return f"[CONNECTOR] Skipped: TLP {level} exceeds max {self._max_tlp.value}" + + if not self.entity_in_scope(obs_type): + return f"[CONNECTOR] {obs_type} not in scope" + + self._errors, self._successes = [], 0 + handlers = { + "ipv4-addr": self._enrich_ipv4, + "stixfile": self._enrich_file, + "file": self._enrich_file, + } + handler = handlers.get(obs_type) + if not handler: + return f"[CONNECTOR] Unsupported type {obs_type}" + + new_objects = handler(stix_entity, obs_id) + + if self._successes == 0 and self._errors: + first = self._errors[0] + raise ValueError(f"[CONNECTOR] All Metras lookups failed: {first}") + + if new_objects: + bundle_objects = ( + stix_objects + [self.converter.author_object()] + new_objects + ) + bundle = self.helper.stix2_create_bundle(bundle_objects) + sent = self.helper.send_stix2_bundle( + bundle, cleanup_inconsistent_bundle=True + ) + return f"[CONNECTOR] Sent {len(sent)} bundle(s)" + return "[CONNECTOR] No Metras fleet data found for this observable" + except Exception as err: # noqa: BLE001 + self.helper.connector_logger.error("[CONNECTOR] Error", {"error": str(err)}) + raise + + # ------------------------------------------------------------------ # + def _enrich_ipv4(self, stix_entity: dict, obs_id: str) -> list: + ip = refang(stix_entity.get("value", "")) + objects = [] + hits = 0 + notes = [] + + alerts = self._safe(self.client.alerts_by_agent_ip, ip) or {} + alert_data = alerts.get("data") or [] + if alert_data: + hits += len(alert_data) + names = ", ".join( + sorted({a.get("alert_name", "?") for a in alert_data})[:10] + ) + notes.append(f"EDR alerts where agent_ip={ip}: {len(alert_data)} ({names})") + + eps = self._safe(self.client.list_endpoints, interface_ip=ip) or {} + endpoints = eps.get("endpoints") or [] + for ep in endpoints: + system = self.converter.create_system( + ep.get("name"), + description=f"Metras endpoint (os={ep.get('os', 'n/a')})", + ) + if system: + objects.append(system) + objects.append( + self.converter.create_relationship( + obs_id, "related-to", system["id"] + ) + ) + if endpoints: + notes.append( + f"Matches {len(endpoints)} fleet endpoint(s): " + + ", ".join(e.get("name", "?") for e in endpoints[:10]) + ) + + if notes: + header = ( + f"Metras fleet hits: {hits} event(s), {len(endpoints)} endpoint(s)." + ) + objects.append( + self.converter.create_note( + obs_id, + "Metras fleet context", + header + "\n" + "\n".join(notes), + labels=["metras"], + ) + ) + return objects + + def _enrich_file(self, stix_entity: dict, obs_id: str) -> list: + hashes = stix_entity.get("hashes") or {} + sha256 = hashes.get("SHA-256") or hashes.get("SHA256") + sha1 = hashes.get("SHA-1") or hashes.get("SHA1") + md5 = hashes.get("MD5") + objects = [] + binary = None + + if sha256 or sha1: + res = self._safe(self.client.binary_by_hash, sha256=sha256, sha1=sha1) or {} + data = res.get("data") or [] + binary = data[0] if data else None + if binary is None and md5: + res = self._safe(self.client.binary_details, md5=md5) or {} + data = res.get("data") or [] + binary = data[0] if data else (res if res.get("md5") else None) + + if not binary: + return objects + + first_seen = binary.get("first_seen") + last_seen = binary.get("last_seen") + content = "\n".join( + [ + f"Name: {binary.get('name', 'n/a')}", + f"Publisher: {binary.get('publisher', 'n/a')}", + f"Signer: {binary.get('signer', 'n/a')}", + f"Signature: {binary.get('signature_status', 'n/a')}", + f"Runnability: {binary.get('runnability_status', 'n/a')}", + f"First seen: {first_seen or 'n/a'}", + f"Last seen: {last_seen or 'n/a'}", + f"First endpoint: {binary.get('first_endpoint_name', 'n/a')}", + ] + ) + objects.append( + self.converter.create_note( + obs_id, "Metras binary inventory", content, labels=["metras"] + ) + ) + ep = binary.get("first_endpoint_name") + if ep: + system = self.converter.create_system(ep, description="Metras endpoint") + if system: + objects.append(system) + objects.append( + self.converter.create_relationship( + obs_id, "related-to", system["id"] + ) + ) + return objects diff --git a/internal-enrichment/metras/src/connector/converter_to_stix.py b/internal-enrichment/metras/src/connector/converter_to_stix.py new file mode 100644 index 00000000000..61fe29cc699 --- /dev/null +++ b/internal-enrichment/metras/src/connector/converter_to_stix.py @@ -0,0 +1,82 @@ +"""STIX conversion for the Metras Enrichment connector (INTERNAL_ENRICHMENT). + +Per-observable outputs: a context Note (fleet hit summary), an optional System +identity (matched fleet endpoint, an internal asset — not an IOC) and a related-to +Relationship linking them. No Sightings or Infrastructure objects are emitted. +""" + +import uuid + +import stix2 +from pycti import Identity, OpenCTIConnectorHelper, StixCoreRelationship + +_NOTE_NAMESPACE = uuid.UUID("9b3c7d15-ca92-4d5e-8a4d-2e2c1b4e7a9b") + + +class ConverterToStix: + def __init__(self, helper: OpenCTIConnectorHelper) -> None: + self.helper = helper + self.author = self._create_author() + self._confidence = getattr(helper, "connect_confidence_level", None) or 50 + + @staticmethod + def _create_author() -> stix2.Identity: + return stix2.Identity( + id=Identity.generate_id(name="Metras", identity_class="organization"), + name="Metras", + identity_class="organization", + description="Metras endpoint detection & response (EDR) platform.", + allow_custom=True, + ) + + def author_object(self) -> stix2.Identity: + return self.author + + def create_note( + self, + observable_id: str, + abstract: str, + content: str, + labels: list | None = None, + ) -> stix2.Note: + note_seed = f"metras-note-{observable_id}-{abstract}" + note_id = f"note--{uuid.uuid5(_NOTE_NAMESPACE, note_seed)}" + return stix2.Note( + id=note_id, + abstract=abstract, + content=content, + object_refs=[observable_id], + created_by_ref=self.author["id"], + labels=labels or [], + confidence=self._confidence, + allow_custom=True, + ) + + def create_system( + self, name: str | None, description: str | None = None + ) -> stix2.Identity | None: + """A fleet endpoint/host as a System identity (internal asset, not an IOC).""" + if not name: + return None + return stix2.Identity( + id=Identity.generate_id(name=name, identity_class="system"), + name=name, + identity_class="system", + description=description, + created_by_ref=self.author["id"], + confidence=self._confidence, + allow_custom=True, + ) + + def create_relationship( + self, source_id: str, rel_type: str, target_id: str + ) -> stix2.Relationship: + return stix2.Relationship( + id=StixCoreRelationship.generate_id(rel_type, source_id, target_id), + relationship_type=rel_type, + source_ref=source_id, + target_ref=target_id, + created_by_ref=self.author["id"], + confidence=self._confidence, + allow_custom=True, + ) diff --git a/internal-enrichment/metras/src/connector/settings.py b/internal-enrichment/metras/src/connector/settings.py new file mode 100644 index 00000000000..98d20670cc3 --- /dev/null +++ b/internal-enrichment/metras/src/connector/settings.py @@ -0,0 +1,52 @@ +"""Pydantic settings for the Metras Enrichment connector (INTERNAL_ENRICHMENT).""" + +from connectors_sdk import ( + BaseConfigModel, + BaseConnectorSettings, + BaseInternalEnrichmentConnectorConfig, + ListFromString, +) +from connectors_sdk.models.enums import TLPLevel +from pydantic import Field, HttpUrl, SecretStr + + +class InternalEnrichmentConnectorConfig(BaseInternalEnrichmentConnectorConfig): + name: str = Field( + default="Metras-Enrichment", + examples=["Metras-Enrichment"], + ) + scope: ListFromString = Field( + default=["IPv4-Addr", "StixFile"], + description="Entity types this connector enriches.", + examples=[["IPv4-Addr", "StixFile"]], + ) + auto: bool = Field(default=False, examples=[False]) + + +class MetrasConfig(BaseConfigModel): + api_base_url: HttpUrl = Field( + default=HttpUrl("https://api.metras.sa/api"), + description="Base URL of the Metras API.", + examples=["https://api.metras.sa/api"], + ) + api_key: SecretStr = Field( + description="Metras API key (X-API-KEY header).", + examples=["ChangeMe"], + ) + verify_ssl: bool = Field( + default=True, + description="Verify TLS certificates.", + examples=[True], + ) + max_tlp: TLPLevel = Field( + default=TLPLevel.AMBER_STRICT, + description="Maximum TLP level the connector will enrich.", + examples=["amber+strict"], + ) + + +class ConnectorSettings(BaseConnectorSettings): + connector: InternalEnrichmentConnectorConfig = Field( + default_factory=InternalEnrichmentConnectorConfig + ) + metras: MetrasConfig = Field(default_factory=MetrasConfig) diff --git a/internal-enrichment/metras/src/connector/utils.py b/internal-enrichment/metras/src/connector/utils.py new file mode 100644 index 00000000000..c213d7c3c70 --- /dev/null +++ b/internal-enrichment/metras/src/connector/utils.py @@ -0,0 +1,31 @@ +"""Pure-function utilities for the Metras Enrichment connector. + +No side effects, no HTTP, no STIX, no config access. +""" + +import ipaddress + + +def refang(value: str) -> str: + """Remove common defanging so values match the external API.""" + if not value: + return value + v = value + v = v.replace("[.]", ".").replace("(.)", ".").replace("{.}", ".") + v = v.replace("[:]", ":").replace("[://]", "://") + v = v.replace("hxxp://", "http://").replace("hxxps://", "https://") + v = v.replace("hXXp://", "http://").replace("hXXps://", "https://") + v = v.replace("[at]", "@").replace("[@]", "@") + v = v.replace("[dot]", ".") + return v.strip() + + +def is_valid_ipv4(value: str) -> bool: + try: + return isinstance(ipaddress.ip_address(value), ipaddress.IPv4Address) + except (ValueError, TypeError): + return False + + +def is_valid_url(value: str) -> bool: + return isinstance(value, str) and value.startswith(("http://", "https://")) diff --git a/internal-enrichment/metras/src/main.py b/internal-enrichment/metras/src/main.py new file mode 100644 index 00000000000..31c89f519db --- /dev/null +++ b/internal-enrichment/metras/src/main.py @@ -0,0 +1,17 @@ +import traceback + +from connector import ConnectorSettings, MetrasEnrichmentConnector +from pycti import OpenCTIConnectorHelper + +if __name__ == "__main__": + try: + settings = ConnectorSettings() + helper = OpenCTIConnectorHelper( + config=settings.to_helper_config(), + playbook_compatible=True, + ) + connector = MetrasEnrichmentConnector(config=settings, helper=helper) + connector.run() + except Exception: + traceback.print_exc() + exit(1) diff --git a/internal-enrichment/metras/src/metras_client/__init__.py b/internal-enrichment/metras/src/metras_client/__init__.py new file mode 100644 index 00000000000..9421cb9c4ab --- /dev/null +++ b/internal-enrichment/metras/src/metras_client/__init__.py @@ -0,0 +1,3 @@ +from metras_client.api_client import MetrasAPIError, MetrasClient + +__all__ = ["MetrasClient", "MetrasAPIError"] diff --git a/internal-enrichment/metras/src/metras_client/api_client.py b/internal-enrichment/metras/src/metras_client/api_client.py new file mode 100644 index 00000000000..258a7d293fa --- /dev/null +++ b/internal-enrichment/metras/src/metras_client/api_client.py @@ -0,0 +1,258 @@ +"""Shared Metras API client. + +This module is identical across the metras-feed, metras-enrichment and +metras-stream connectors (copied verbatim — no cross-directory imports). +It wraps the Metras REST API (https://api.metras.sa/api), authenticated with the +``X-API-KEY`` header, and exposes thin methods used by all three connectors. +""" + +from collections.abc import Iterator +from urllib.parse import quote + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + + +class MetrasAPIError(Exception): + """Raised when the Metras API returns an error or is unreachable.""" + + def __init__(self, message: str, status_code: int | None = None) -> None: + self.message = message + self.status_code = status_code + super().__init__(self.message) + + +class MetrasClient: + """Thin HTTP client for the Metras API. + + Methods return parsed JSON (dict) and raise :class:`MetrasAPIError` on + failure. Parsing of business data belongs in the converters, not here. + """ + + # Conservative default timeouts (connect, read) + _TIMEOUT = (10, 60) + + def __init__( + self, + helper, + base_url: str, + api_key: str, + verify_ssl: bool = True, + ) -> None: + self.helper = helper + self.base_url = str(base_url).rstrip("/") + self.verify_ssl = verify_ssl + + self.session = requests.Session() + # Explicit for security scanners; honour the configured value. + self.session.verify = verify_ssl + self.session.headers.update( + { + "X-API-KEY": api_key, + "Accept": "application/json", + "Content-Type": "application/json", + } + ) + + # Retry on transient/server errors, honouring Retry-After on 429. + retry = Retry( + total=3, + connect=3, + read=3, + backoff_factor=1, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods=("GET", "POST", "PATCH", "DELETE"), + respect_retry_after_header=True, + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + # ------------------------------------------------------------------ # + # Low-level request helpers + # ------------------------------------------------------------------ # + def _request(self, method: str, path: str, params=None, json_data=None) -> dict: + url = f"{self.base_url}{path}" + try: + resp = self.session.request( + method, + url, + params=params, + json=json_data, + timeout=self._TIMEOUT, + ) + except requests.exceptions.RequestException as exc: + raise MetrasAPIError(f"Request to {path} failed: {exc}") from exc + + self.helper.connector_logger.debug( + "[API] Request", + {"method": method, "path": path, "status": resp.status_code}, + ) + + if resp.status_code in (401, 403): + raise MetrasAPIError( + f"Authentication failed (HTTP {resp.status_code}) for {path}. " + "Check METRAS_API_KEY.", + resp.status_code, + ) + if resp.status_code >= 400: + raise MetrasAPIError( + f"Metras API error (HTTP {resp.status_code}) for {path}: " + f"{resp.text[:500]}", + resp.status_code, + ) + + # Some write endpoints return 202/empty bodies. + if not resp.content: + return {} + try: + return resp.json() + except ValueError: + return {"_raw": resp.text} + + def _get(self, path: str, params=None) -> dict: + return self._request("GET", path, params=params) + + def _post(self, path: str, json_data=None) -> dict: + return self._request("POST", path, json_data=json_data) + + def _patch(self, path: str, json_data=None) -> dict: + return self._request("PATCH", path, json_data=json_data) + + def _delete(self, path: str) -> dict: + return self._request("DELETE", path) + + # ------------------------------------------------------------------ # + # Connectivity / startup + # ------------------------------------------------------------------ # + def ping(self) -> bool: + """Lightweight connectivity + auth check. + + Metras has no dedicated health endpoint, so a tiny endpoints list call + is used. Raises :class:`MetrasAPIError` on failure. + """ + self._get("/v1/endpoints", params={"status": "activated"}) + return True + + # ------------------------------------------------------------------ # + # Feed (EXTERNAL_IMPORT) endpoints + # ------------------------------------------------------------------ # + def list_edr_alerts(self, page_size: int = 50, skip: int = 0, **filters) -> dict: + """One page of EDR 2.0 alerts. Response: {totalCount, more, data[]}. + + Note: this endpoint has no fromTime/toTime — callers filter client-side + on ``last_occurrence_time``. + """ + params = {"limit": page_size, "skip": skip} + params.update({k: v for k, v in filters.items() if v is not None}) + return self._get("/v1/edr/alerts", params=params) + + def iter_edr_alerts( + self, page_size: int = 50, max_pages: int = 200, **filters + ) -> Iterator[dict]: + """Yield EDR alert records across pages until ``more`` is false.""" + skip = 0 + for _ in range(max_pages): + payload = self.list_edr_alerts(page_size=page_size, skip=skip, **filters) + data = payload.get("data") or [] + for record in data: + yield record + if not payload.get("more") or not data: + break + skip += page_size + + def list_binaries( + self, + from_time: str | None = None, + to_time: str | None = None, + page_size: int = 50, + skip: int = 0, + query: str | None = None, + ) -> dict: + """One page of binary inventory. Response: {totalCount, data[]}.""" + params = {"limit": page_size, "skip": skip, "full": "true"} + if from_time: + params["fromTime"] = from_time + if to_time: + params["toTime"] = to_time + if query: + params["query"] = query + return self._get("/v1/edr/binary/list", params=params) + + def iter_binaries( + self, + from_time: str | None = None, + page_size: int = 50, + max_pages: int = 200, + query: str | None = None, + ) -> Iterator[dict]: + """Yield binary records across pages (stops when a short page returns).""" + skip = 0 + for _ in range(max_pages): + payload = self.list_binaries( + from_time=from_time, page_size=page_size, skip=skip, query=query + ) + data = payload.get("data") or [] + for record in data: + yield record + if len(data) < page_size: + break + skip += page_size + + def binary_details(self, md5: str | None = None, name: str | None = None) -> dict: + """Single binary by md5 or name.""" + params = {} + if md5: + params["md5"] = md5 + if name: + params["name"] = name + return self._get("/v1/edr/binary/details", params=params) + + def list_endpoints(self, **filters) -> dict: + """Endpoint/asset inventory. Response: {endpoints[]}.""" + params = {k: v for k, v in filters.items() if v is not None} + return self._get("/v1/endpoints", params=params) + + # ------------------------------------------------------------------ # + # Enrichment (INTERNAL_ENRICHMENT) endpoints + # ------------------------------------------------------------------ # + def threat_details(self, **filters) -> dict: + """Network incident details, filterable by sourceIP/destinationIP/url.""" + params = {k: v for k, v in filters.items() if v is not None} + return self._get("/v4/threats/detail", params=params) + + def binary_by_hash( + self, sha256: str | None = None, sha1: str | None = None + ) -> dict: + """Look up a binary by sha256/sha1 via the list endpoint's query param.""" + if sha256: + return self.list_binaries(query=f"sha256:{sha256}") + if sha1: + return self.list_binaries(query=f"sha1:{sha1}") + return {"data": []} + + def alerts_by_agent_ip(self, agent_ip: str, page_size: int = 50) -> dict: + return self.list_edr_alerts(page_size=page_size, agent_ip=agent_ip) + + # ------------------------------------------------------------------ # + # Stream (STREAM) endpoints — custom blocklist (file-path only) + # ------------------------------------------------------------------ # + def create_blocklist(self, items: list[dict]) -> dict: + """Create one or more custom blocklists (file_paths). Body is an array.""" + return self._post("/v1/custom-blocklist", json_data=items) + + def list_blocklists(self, name: str | None = None, page_size: int = 50) -> dict: + params = {"limit": page_size} + if name: + params["name"] = name + return self._get("/v1/custom-blocklist", params=params) + + def update_blocklist(self, blocklist_id: str, patch: dict) -> dict: + safe_id = quote(str(blocklist_id), safe="") + return self._patch(f"/v1/custom-blocklist/{safe_id}", json_data=patch) + + def delete_blocklist(self, blocklist_id: str) -> dict: + safe_id = quote(str(blocklist_id), safe="") + return self._delete(f"/v1/custom-blocklist/{safe_id}") diff --git a/internal-enrichment/metras/src/requirements.txt b/internal-enrichment/metras/src/requirements.txt new file mode 100644 index 00000000000..fb7e773723b --- /dev/null +++ b/internal-enrichment/metras/src/requirements.txt @@ -0,0 +1,6 @@ +pycti==7.260529.0 +pydantic~=2.11.3 +stix2>=3.0.1,<4.0.0 +requests>=2.32.0,<3.0.0 +pyyaml>=6.0.1,<7.0.0 +connectors-sdk @ git+https://github.com/OpenCTI-Platform/connectors.git@7.260529.0#subdirectory=connectors-sdk diff --git a/internal-enrichment/metras/tests/conftest.py b/internal-enrichment/metras/tests/conftest.py new file mode 100644 index 00000000000..5ee8fc0e226 --- /dev/null +++ b/internal-enrichment/metras/tests/conftest.py @@ -0,0 +1,4 @@ +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src")) diff --git a/internal-enrichment/metras/tests/test-requirements.txt b/internal-enrichment/metras/tests/test-requirements.txt new file mode 100644 index 00000000000..6ef4498bfff --- /dev/null +++ b/internal-enrichment/metras/tests/test-requirements.txt @@ -0,0 +1,2 @@ +-r ../src/requirements.txt +pytest>=8.0.0 diff --git a/internal-enrichment/metras/tests/test_connector/test_client.py b/internal-enrichment/metras/tests/test_connector/test_client.py new file mode 100644 index 00000000000..06cdcfaf82e --- /dev/null +++ b/internal-enrichment/metras/tests/test_connector/test_client.py @@ -0,0 +1,72 @@ +"""Unit tests for the shared MetrasClient (HTTP layer mocked).""" + +from unittest.mock import MagicMock + +import pytest +from metras_client import MetrasAPIError, MetrasClient + + +class FakeResp: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self._payload = payload + self.text = text + self.content = b"x" if (payload is not None or text) else b"" + + def json(self): + if self._payload is None: + raise ValueError("no json") + return self._payload + + +def _client(responses): + client = MetrasClient(helper=MagicMock(), base_url="http://x/api", api_key="k") + session = MagicMock() + session.request.side_effect = list(responses) + client.session = session + return client + + +def test_ping_ok(): + assert _client([FakeResp(200, {"endpoints": []})]).ping() is True + + +def test_auth_error_raises_with_status(): + client = _client([FakeResp(401, text="nope")]) + with pytest.raises(MetrasAPIError) as exc: + client.ping() + assert exc.value.status_code == 401 + + +def test_server_error_raises(): + with pytest.raises(MetrasAPIError): + _client([FakeResp(500, text="boom")])._get("/v1/endpoints") + + +def test_iter_edr_alerts_paginates_until_more_false(): + client = _client( + [ + FakeResp(200, {"data": [{"id": "1"}, {"id": "2"}], "more": True}), + FakeResp(200, {"data": [{"id": "3"}], "more": False}), + ] + ) + assert [a["id"] for a in client.iter_edr_alerts(page_size=2)] == ["1", "2", "3"] + + +def test_iter_binaries_stops_on_short_page(): + client = _client([FakeResp(200, {"data": [{"md5": "a"}]})]) + assert len(list(client.iter_binaries(page_size=50))) == 1 + + +def test_create_blocklist_handles_empty_202(): + assert _client([FakeResp(202)]).create_blocklist([{"name": "x"}]) == {} + + +def test_list_blocklists_filters_by_name(): + client = _client([FakeResp(200, {"data": [{"id": "b1", "name": "n"}]})]) + assert client.list_blocklists(name="n")["data"][0]["id"] == "b1" + + +def test_binary_by_hash_uses_query(): + client = _client([FakeResp(200, {"data": [{"sha256": "abc"}]})]) + assert client.binary_by_hash(sha256="abc")["data"][0]["sha256"] == "abc" diff --git a/internal-enrichment/metras/tests/test_connector/test_orchestration.py b/internal-enrichment/metras/tests/test_connector/test_orchestration.py new file mode 100644 index 00000000000..90faf09c72c --- /dev/null +++ b/internal-enrichment/metras/tests/test_connector/test_orchestration.py @@ -0,0 +1,88 @@ +"""Orchestration tests for the Enrichment connector (helper + client mocked).""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from connector.connector import MetrasEnrichmentConnector +from connectors_sdk.models.enums import TLPLevel +from pydantic import SecretStr + +IPV4_ID = "ipv4-addr--11111111-1111-4111-8111-111111111111" +TLP_RED = "marking-definition--5e57c739-391a-4eb3-b6be-7d15ca92d5ed" + + +def _enr(max_tlp=TLPLevel.AMBER_STRICT): + cfg = SimpleNamespace( + metras=SimpleNamespace( + api_base_url="http://x/api", + api_key=SecretStr("k"), + verify_ssl=True, + max_tlp=max_tlp, + ), + connector=SimpleNamespace(scope=["IPv4-Addr", "StixFile"]), + ) + helper = MagicMock() + helper.connect_confidence_level = 50 + helper.stix2_create_bundle.return_value = "{}" + helper.send_stix2_bundle.return_value = [1] + conn = MetrasEnrichmentConnector(cfg, helper) + conn.client = MagicMock() + return conn, helper + + +def test_out_of_scope_returns_message(): + conn, _ = _enr() + msg = conn.process_message( + {"stix_entity": {"type": "Domain-Name", "id": "domain-name--x"}} + ) + assert "not in scope" in msg + + +def test_file_alias_is_in_scope(): + conn, _ = _enr() + assert conn.entity_in_scope("file") is True # file -> stixfile alias + + +def test_missing_stix_id_aborts(): + conn, _ = _enr() + with pytest.raises(ValueError): + conn.process_message({"stix_entity": {"type": "IPv4-Addr", "value": "1.2.3.4"}}) + + +def test_tlp_gate_blocks_above_max(): + conn, _ = _enr(max_tlp=TLPLevel.GREEN) + msg = conn.process_message( + { + "stix_entity": { + "type": "IPv4-Addr", + "id": IPV4_ID, + "value": "1.2.3.4", + "object_marking_refs": [TLP_RED], + } + } + ) + assert "exceeds max" in msg + + +def test_ipv4_hit_sends_bundle_with_cleanup_flag(): + conn, helper = _enr() + conn.client.alerts_by_agent_ip.return_value = {"data": [{"alert_name": "r1"}]} + conn.client.list_endpoints.return_value = {"endpoints": []} + msg = conn.process_message( + {"stix_entity": {"type": "IPv4-Addr", "id": IPV4_ID, "value": "10.0.0.1"}} + ) + assert "Sent" in msg + _, kwargs = helper.send_stix2_bundle.call_args + assert kwargs.get("cleanup_inconsistent_bundle") is True + + +def test_all_lookups_fail_raises(): + conn, _ = _enr() + boom = MagicMock(side_effect=Exception("x")) + conn.client.alerts_by_agent_ip = boom + conn.client.list_endpoints = boom + with pytest.raises(ValueError): + conn.process_message( + {"stix_entity": {"type": "IPv4-Addr", "id": IPV4_ID, "value": "10.0.0.1"}} + ) diff --git a/internal-enrichment/metras/tests/test_connector/test_settings.py b/internal-enrichment/metras/tests/test_connector/test_settings.py new file mode 100644 index 00000000000..810a5e6a216 --- /dev/null +++ b/internal-enrichment/metras/tests/test_connector/test_settings.py @@ -0,0 +1,27 @@ +"""Settings (config-model) tests for the Metras Enrichment connector.""" + +from connector.settings import InternalEnrichmentConnectorConfig, MetrasConfig +from connectors_sdk.models.enums import TLPLevel +from pydantic import SecretStr + + +def test_api_key_is_secret(): + cfg = MetrasConfig(api_key="super-secret-key") + assert isinstance(cfg.api_key, SecretStr) + assert cfg.api_key.get_secret_value() == "super-secret-key" + assert "super-secret-key" not in repr(cfg) + + +def test_max_tlp_default_is_tlplevel(): + cfg = MetrasConfig(api_key="k") + assert cfg.max_tlp is TLPLevel.AMBER_STRICT + assert cfg.max_tlp.value == "amber+strict" + + +def test_scope_parses_comma_string(): + # ListFromString turns "A,B" into ["A","B"]. `id` is required by the SDK base + # config (supplied at runtime via CONNECTOR_ID). + cfg = InternalEnrichmentConnectorConfig( + id="a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", scope="IPv4-Addr, StixFile" + ) + assert cfg.scope == ["IPv4-Addr", "StixFile"] diff --git a/internal-enrichment/metras/tests/test_main.py b/internal-enrichment/metras/tests/test_main.py new file mode 100644 index 00000000000..d50e81ee55e --- /dev/null +++ b/internal-enrichment/metras/tests/test_main.py @@ -0,0 +1,13 @@ +"""Unit tests for shared utilities used by the Metras Enrichment connector.""" + +from connector.utils import is_valid_ipv4, is_valid_url, refang + + +def test_refang_for_enrichment(): + assert refang("8[.]8[.]8[.]8") == "8.8.8.8" + assert refang("hxxp://bad[.]example") == "http://bad.example" + + +def test_validators(): + assert is_valid_ipv4("10.200.0.214") + assert is_valid_url("https://dashboard.metras.sa/") From 32cb0c6599cae28f24f560cd2ed1742fd4154201 Mon Sep 17 00:00:00 2001 From: Khidr6g <270672697+Khidr6G@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:54:35 +0300 Subject: [PATCH 2/8] fix(metras): none-safe refang/joins, fail-loud client, converter tests, config schema (#7042) --- .../__metadata__/connector_config_schema.json | 116 ++++++++++++++++++ .../metras/src/connector/connector.py | 4 +- .../metras/src/connector/utils.py | 4 +- .../metras/src/metras_client/api_client.py | 8 +- .../tests/test_connector/test_client.py | 7 ++ .../tests/test_connector/test_converter.py | 50 ++++++++ 6 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 internal-enrichment/metras/__metadata__/connector_config_schema.json create mode 100644 internal-enrichment/metras/tests/test_connector/test_converter.py diff --git a/internal-enrichment/metras/__metadata__/connector_config_schema.json b/internal-enrichment/metras/__metadata__/connector_config_schema.json new file mode 100644 index 00000000000..fb9d225afdd --- /dev/null +++ b/internal-enrichment/metras/__metadata__/connector_config_schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.filigran.io/connectors/w_config.schema.json", + "type": "object", + "properties": { + "OPENCTI_URL": { + "description": "The base URL of the OpenCTI instance.", + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + "OPENCTI_TOKEN": { + "description": "The API token to connect to OpenCTI.", + "type": "string" + }, + "CONNECTOR_NAME": { + "default": "Metras-Enrichment", + "examples": [ + "Metras-Enrichment" + ], + "type": "string" + }, + "CONNECTOR_SCOPE": { + "default": [ + "IPv4-Addr", + "StixFile" + ], + "description": "Entity types this connector enriches.", + "examples": [ + [ + "IPv4-Addr", + "StixFile" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "CONNECTOR_LOG_LEVEL": { + "default": "error", + "description": "The minimum level of logs to display.", + "enum": [ + "debug", + "info", + "warn", + "warning", + "error" + ], + "type": "string" + }, + "CONNECTOR_TYPE": { + "const": "INTERNAL_ENRICHMENT", + "default": "INTERNAL_ENRICHMENT", + "type": "string" + }, + "CONNECTOR_AUTO": { + "default": false, + "examples": [ + false + ], + "type": "boolean" + }, + "METRAS_API_BASE_URL": { + "default": "https://api.metras.sa/api", + "description": "Base URL of the Metras API.", + "examples": [ + "https://api.metras.sa/api" + ], + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + "METRAS_API_KEY": { + "description": "Metras API key (X-API-KEY header).", + "examples": [ + "ChangeMe" + ], + "format": "password", + "type": "string", + "writeOnly": true + }, + "METRAS_VERIFY_SSL": { + "default": true, + "description": "Verify TLS certificates.", + "examples": [ + true + ], + "type": "boolean" + }, + "METRAS_MAX_TLP": { + "description": "Maximum TLP level the connector will enrich.", + "enum": [ + "clear", + "white", + "green", + "amber", + "amber+strict", + "red" + ], + "type": "string", + "default": "amber+strict", + "examples": [ + "amber+strict" + ] + } + }, + "required": [ + "OPENCTI_URL", + "OPENCTI_TOKEN", + "METRAS_API_KEY" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/internal-enrichment/metras/src/connector/connector.py b/internal-enrichment/metras/src/connector/connector.py index ebc27e7db63..0af2653e2e0 100644 --- a/internal-enrichment/metras/src/connector/connector.py +++ b/internal-enrichment/metras/src/connector/connector.py @@ -178,7 +178,7 @@ def _enrich_ipv4(self, stix_entity: dict, obs_id: str) -> list: if alert_data: hits += len(alert_data) names = ", ".join( - sorted({a.get("alert_name", "?") for a in alert_data})[:10] + sorted({a.get("alert_name") or "?" for a in alert_data})[:10] ) notes.append(f"EDR alerts where agent_ip={ip}: {len(alert_data)} ({names})") @@ -199,7 +199,7 @@ def _enrich_ipv4(self, stix_entity: dict, obs_id: str) -> list: if endpoints: notes.append( f"Matches {len(endpoints)} fleet endpoint(s): " - + ", ".join(e.get("name", "?") for e in endpoints[:10]) + + ", ".join(e.get("name") or "?" for e in endpoints[:10]) ) if notes: diff --git a/internal-enrichment/metras/src/connector/utils.py b/internal-enrichment/metras/src/connector/utils.py index c213d7c3c70..c0db2035b04 100644 --- a/internal-enrichment/metras/src/connector/utils.py +++ b/internal-enrichment/metras/src/connector/utils.py @@ -6,10 +6,10 @@ import ipaddress -def refang(value: str) -> str: +def refang(value: str | None) -> str: """Remove common defanging so values match the external API.""" if not value: - return value + return "" v = value v = v.replace("[.]", ".").replace("(.)", ".").replace("{.}", ".") v = v.replace("[:]", ":").replace("[://]", "://") diff --git a/internal-enrichment/metras/src/metras_client/api_client.py b/internal-enrichment/metras/src/metras_client/api_client.py index 258a7d293fa..2dcf3e14129 100644 --- a/internal-enrichment/metras/src/metras_client/api_client.py +++ b/internal-enrichment/metras/src/metras_client/api_client.py @@ -109,8 +109,12 @@ def _request(self, method: str, path: str, params=None, json_data=None) -> dict: return {} try: return resp.json() - except ValueError: - return {"_raw": resp.text} + except ValueError as exc: + raise MetrasAPIError( + f"Non-JSON response from {path} (HTTP {resp.status_code}): " + f"{resp.text[:200]}", + resp.status_code, + ) from exc def _get(self, path: str, params=None) -> dict: return self._request("GET", path, params=params) diff --git a/internal-enrichment/metras/tests/test_connector/test_client.py b/internal-enrichment/metras/tests/test_connector/test_client.py index 06cdcfaf82e..cc5498b8158 100644 --- a/internal-enrichment/metras/tests/test_connector/test_client.py +++ b/internal-enrichment/metras/tests/test_connector/test_client.py @@ -43,6 +43,13 @@ def test_server_error_raises(): _client([FakeResp(500, text="boom")])._get("/v1/endpoints") +def test_non_json_response_raises(): + # A 200 with a non-JSON body (WAF/HTML/proxy error page) must fail loudly, + # not silently return an unparsable payload that looks like an empty result. + with pytest.raises(MetrasAPIError): + _client([FakeResp(200, text="blocked")])._get("/v1/endpoints") + + def test_iter_edr_alerts_paginates_until_more_false(): client = _client( [ diff --git a/internal-enrichment/metras/tests/test_connector/test_converter.py b/internal-enrichment/metras/tests/test_connector/test_converter.py new file mode 100644 index 00000000000..43d4f072843 --- /dev/null +++ b/internal-enrichment/metras/tests/test_connector/test_converter.py @@ -0,0 +1,50 @@ +"""Converter tests for the Metras Enrichment connector (no live OpenCTI needed).""" + +from unittest.mock import MagicMock + +from connector.converter_to_stix import ConverterToStix + + +def _conv(): + helper = MagicMock() + helper.connect_confidence_level = 50 + return ConverterToStix(helper) + + +_OBS = "ipv4-addr--11111111-1111-4111-8111-111111111111" +_TGT = "identity--22222222-2222-4222-8222-222222222222" + + +def test_author_is_organization_identity(): + author = _conv().author_object() + assert author["type"] == "identity" + assert author["identity_class"] == "organization" + + +def test_create_note_is_deterministic_and_targets_observable(): + conv = _conv() + n1 = conv.create_note(_OBS, "Metras fleet context", "body", labels=["metras"]) + n2 = conv.create_note(_OBS, "Metras fleet context", "body") + assert n1["type"] == "note" + assert n1["object_refs"] == [_OBS] + assert "metras" in n1["labels"] + # Deterministic on (observable, abstract) so re-enrichment updates, not duplicates. + assert n1["id"] == n2["id"] + + +def test_create_system_is_system_identity_and_none_safe(): + conv = _conv() + system = conv.create_system("HOST-1", description="endpoint os=windows") + assert system["type"] == "identity" + assert system["identity_class"] == "system" + # No name -> nothing emitted (avoids empty System identities). + assert conv.create_system(None) is None + assert conv.create_system("") is None + + +def test_create_relationship_shape(): + rel = _conv().create_relationship(_OBS, "related-to", _TGT) + assert rel["type"] == "relationship" + assert rel["relationship_type"] == "related-to" + assert rel["source_ref"] == _OBS + assert rel["target_ref"] == _TGT From a2d8c91f7fc9541ce93d0737bd22823dbabeebc8 Mon Sep 17 00:00:00 2001 From: Khidr6g <270672697+Khidr6G@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:38:58 +0300 Subject: [PATCH 3/8] fix(metras): set manifest source_code, add file-enrichment test (#7042) --- .../__metadata__/connector_manifest.json | 2 +- .../test_connector/test_orchestration.py | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/internal-enrichment/metras/__metadata__/connector_manifest.json b/internal-enrichment/metras/__metadata__/connector_manifest.json index 4fb5ecf83c6..8aac525a7d6 100644 --- a/internal-enrichment/metras/__metadata__/connector_manifest.json +++ b/internal-enrichment/metras/__metadata__/connector_manifest.json @@ -11,7 +11,7 @@ "max_confidence_level": 50, "support_version": ">=7.260529.0", "subscription_link": null, - "source_code": null, + "source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/internal-enrichment/metras", "manager_supported": false, "container_version": "1.0.0", "container_image": "opencti/connector-metras-enrichment", diff --git a/internal-enrichment/metras/tests/test_connector/test_orchestration.py b/internal-enrichment/metras/tests/test_connector/test_orchestration.py index 90faf09c72c..713bdc2ac00 100644 --- a/internal-enrichment/metras/tests/test_connector/test_orchestration.py +++ b/internal-enrichment/metras/tests/test_connector/test_orchestration.py @@ -9,6 +9,7 @@ from pydantic import SecretStr IPV4_ID = "ipv4-addr--11111111-1111-4111-8111-111111111111" +FILE_ID = "file--22222222-2222-4222-8222-222222222222" TLP_RED = "marking-definition--5e57c739-391a-4eb3-b6be-7d15ca92d5ed" @@ -77,6 +78,40 @@ def test_ipv4_hit_sends_bundle_with_cleanup_flag(): assert kwargs.get("cleanup_inconsistent_bundle") is True +def test_file_hit_builds_note_and_system_and_sends_bundle(): + conn, helper = _enr() + conn.client.binary_by_hash.return_value = { + "data": [ + { + "name": "evil.dll", + "publisher": "Unknown", + "signer": "n/a", + "signature_status": "Unsigned", + "runnability_status": "banned", + "first_seen": "2025-11-01T00:00:00Z", + "last_seen": "2025-11-12T00:00:00Z", + "first_endpoint_name": "HOST-1", + } + ] + } + msg = conn.process_message( + { + "stix_entity": { + "type": "StixFile", + "id": FILE_ID, + "hashes": {"SHA-256": "a" * 64}, + } + } + ) + assert "Sent" in msg + _, kwargs = helper.send_stix2_bundle.call_args + assert kwargs.get("cleanup_inconsistent_bundle") is True + # The enriched bundle carries the binary Note + matched System identity. + objects = helper.stix2_create_bundle.call_args[0][0] + types = {o["type"] for o in objects} + assert "note" in types and "identity" in types and "relationship" in types + + def test_all_lookups_fail_raises(): conn, _ = _enr() boom = MagicMock(side_effect=Exception("x")) From d88a33172b1cfb3b1538995ef074fb1f4d5243b1 Mon Sep 17 00:00:00 2001 From: Khidr6g <270672697+Khidr6G@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:48:15 +0300 Subject: [PATCH 4/8] fix(metras): address review feedback on internal-enrichment connector (#7042) --- internal-enrichment/metras/README.md | 2 +- .../__metadata__/connector_manifest.json | 2 +- .../metras/src/connector/connector.py | 19 +++++++++++++++---- .../metras/src/metras_client/api_client.py | 2 +- internal-enrichment/metras/tests/conftest.py | 2 +- .../test_connector/test_orchestration.py | 13 +++++++++++++ 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/internal-enrichment/metras/README.md b/internal-enrichment/metras/README.md index 5c1aa1177c5..5ab6e1f7558 100644 --- a/internal-enrichment/metras/README.md +++ b/internal-enrichment/metras/README.md @@ -39,7 +39,7 @@ platform when you enrich an observable in OpenCTI. Adds a context **Note** (and | `CONNECTOR_NAME` | no | `Metras-Enrichment` | Connector name | | `CONNECTOR_SCOPE` | no | `IPv4-Addr,StixFile` | Observable types to enrich | | `CONNECTOR_AUTO` | no | `false` | Auto-enrich on observable creation | -| `CONNECTOR_LOG_LEVEL` | no | `info` | Log level | +| `CONNECTOR_LOG_LEVEL` | no | `error` | Log level | | `METRAS_API_BASE_URL` | no | `https://api.metras.sa/api` | Metras API base URL | | `METRAS_API_KEY` | yes | — | Metras API key | | `METRAS_VERIFY_SSL` | no | `true` | Verify TLS certificates | diff --git a/internal-enrichment/metras/__metadata__/connector_manifest.json b/internal-enrichment/metras/__metadata__/connector_manifest.json index 8aac525a7d6..acf18d9ab42 100644 --- a/internal-enrichment/metras/__metadata__/connector_manifest.json +++ b/internal-enrichment/metras/__metadata__/connector_manifest.json @@ -2,7 +2,7 @@ "title": "Metras Enrichment Connector", "slug": "metras-enrichment", "description": "Enriches IPv4 and file-hash observables by querying Metras for fleet presence (EDR alerts, endpoint inventory, binary inventory), returning context Notes and system-identity links ('have I seen this in my environment?').", - "short_description": "Enrichment connector for Metras fleet sightings", + "short_description": "Enrichment connector for Metras fleet presence lookups", "logo": null, "use_cases": ["Enrichment & Analysis"], "verified": false, diff --git a/internal-enrichment/metras/src/connector/connector.py b/internal-enrichment/metras/src/connector/connector.py index 0af2653e2e0..d3a08591964 100644 --- a/internal-enrichment/metras/src/connector/connector.py +++ b/internal-enrichment/metras/src/connector/connector.py @@ -61,7 +61,8 @@ def run(self) -> None: ) except MetrasAPIError as exc: self.helper.connector_logger.error( - "[CONNECTOR] Metras API ping failed at startup", {"error": str(exc)} + "[CONNECTOR] Metras API ping failed at startup", + meta={"error": str(exc)}, ) # Listener still starts so the connector registers; per-message calls # will surface auth errors clearly to the analyst. @@ -97,7 +98,7 @@ def _safe(self, func: Callable, *args, **kwargs) -> Any: except Exception as exc: # noqa: BLE001 self.helper.connector_logger.error( f"[CONNECTOR] {getattr(func, '__name__', 'call')} failed", - {"error": str(exc)}, + meta={"error": str(exc)}, ) self._errors.append(str(exc)) return None @@ -129,7 +130,7 @@ def process_message(self, data: dict) -> str | None: if level and not self._tlp_allowed(level): self.helper.connector_logger.warning( "[CONNECTOR] Skipped: TLP exceeds max", - {"tlp": level, "max": self._max_tlp.value}, + meta={"tlp": level, "max": self._max_tlp.value}, ) return f"[CONNECTOR] Skipped: TLP {level} exceeds max {self._max_tlp.value}" @@ -163,12 +164,22 @@ def process_message(self, data: dict) -> str | None: return f"[CONNECTOR] Sent {len(sent)} bundle(s)" return "[CONNECTOR] No Metras fleet data found for this observable" except Exception as err: # noqa: BLE001 - self.helper.connector_logger.error("[CONNECTOR] Error", {"error": str(err)}) + self.helper.connector_logger.error( + "[CONNECTOR] Error", meta={"error": str(err)} + ) raise # ------------------------------------------------------------------ # def _enrich_ipv4(self, stix_entity: dict, obs_id: str) -> list: ip = refang(stix_entity.get("value", "")) + # Guard against a missing/empty value: querying Metras with agent_ip="" + # would issue an unexpectedly broad lookup, so skip instead. + if not ip: + self.helper.connector_logger.warning( + "[CONNECTOR] Skipped IPv4 enrichment: empty observable value", + meta={"obs_id": obs_id}, + ) + return [] objects = [] hits = 0 notes = [] diff --git a/internal-enrichment/metras/src/metras_client/api_client.py b/internal-enrichment/metras/src/metras_client/api_client.py index 2dcf3e14129..6eb91f40736 100644 --- a/internal-enrichment/metras/src/metras_client/api_client.py +++ b/internal-enrichment/metras/src/metras_client/api_client.py @@ -88,7 +88,7 @@ def _request(self, method: str, path: str, params=None, json_data=None) -> dict: self.helper.connector_logger.debug( "[API] Request", - {"method": method, "path": path, "status": resp.status_code}, + meta={"method": method, "path": path, "status": resp.status_code}, ) if resp.status_code in (401, 403): diff --git a/internal-enrichment/metras/tests/conftest.py b/internal-enrichment/metras/tests/conftest.py index 5ee8fc0e226..d956842a2b5 100644 --- a/internal-enrichment/metras/tests/conftest.py +++ b/internal-enrichment/metras/tests/conftest.py @@ -1,4 +1,4 @@ import os import sys -sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) diff --git a/internal-enrichment/metras/tests/test_connector/test_orchestration.py b/internal-enrichment/metras/tests/test_connector/test_orchestration.py index 713bdc2ac00..a864799433d 100644 --- a/internal-enrichment/metras/tests/test_connector/test_orchestration.py +++ b/internal-enrichment/metras/tests/test_connector/test_orchestration.py @@ -121,3 +121,16 @@ def test_all_lookups_fail_raises(): conn.process_message( {"stix_entity": {"type": "IPv4-Addr", "id": IPV4_ID, "value": "10.0.0.1"}} ) + + +def test_empty_ipv4_value_skips_lookup(): + # An IPv4 observable with no value must NOT trigger a broad agent_ip="" query. + conn, _ = _enr() + conn.client.alerts_by_agent_ip = MagicMock() + conn.client.list_endpoints = MagicMock() + msg = conn.process_message( + {"stix_entity": {"type": "IPv4-Addr", "id": IPV4_ID, "value": ""}} + ) + assert "No Metras fleet data" in msg + conn.client.alerts_by_agent_ip.assert_not_called() + conn.client.list_endpoints.assert_not_called() From bf467f25f639dfae01c5eff82eb1c753d8dc34b6 Mon Sep 17 00:00:00 2001 From: Khidr6g <270672697+Khidr6G@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:14:31 +0300 Subject: [PATCH 5/8] fix(metras): add required manifest fields for updated schema (#7042) --- .../metras/__metadata__/connector_manifest.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal-enrichment/metras/__metadata__/connector_manifest.json b/internal-enrichment/metras/__metadata__/connector_manifest.json index acf18d9ab42..02c6647f0ab 100644 --- a/internal-enrichment/metras/__metadata__/connector_manifest.json +++ b/internal-enrichment/metras/__metadata__/connector_manifest.json @@ -4,7 +4,10 @@ "description": "Enriches IPv4 and file-hash observables by querying Metras for fleet presence (EDR alerts, endpoint inventory, binary inventory), returning context Notes and system-identity links ('have I seen this in my environment?').", "short_description": "Enrichment connector for Metras fleet presence lookups", "logo": null, - "use_cases": ["Enrichment & Analysis"], + "use_cases": ["Detection & Response Enablement"], + "solution_categories": ["Endpoint Detection & Response"], + "contact": null, + "license_type": "Commercial", "verified": false, "last_verified_date": null, "playbook_supported": true, From b5259c8e5f6c2f8c2bac02407367298b7bb9c98d Mon Sep 17 00:00:00 2001 From: Khidr6g <270672697+Khidr6G@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:09:39 +0300 Subject: [PATCH 6/8] =?UTF-8?q?feat(metras):=20apply=20maintainer=20review?= =?UTF-8?q?=20=E2=80=94=20rename,=20sdk=20models,=20manifest=20(#7042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.dockerignore | 0 .../{metras => metras-enrichment}/Dockerfile | 0 .../{metras => metras-enrichment}/README.md | 4 +- .../__metadata__/connector_manifest.json | 6 +- .../config.yml.sample | 3 +- .../metras-enrichment/docker-compose.yml | 17 +++ .../entrypoint.sh | 0 .../pyproject.toml | 0 .../src/connector/__init__.py | 0 .../src/connector/connector.py | 0 .../src/connector/converter_to_stix.py | 10 +- .../src/connector/settings.py | 12 +- .../src/connector/utils.py | 0 .../{metras => metras-enrichment}/src/main.py | 0 .../src/metras_client/__init__.py | 0 .../src/metras_client/api_client.py | 0 .../src/requirements.txt | 4 +- .../tests/conftest.py | 0 .../tests/test-requirements.txt | 0 .../tests/test_connector/test_client.py | 0 .../tests/test_connector/test_converter.py | 0 .../test_connector/test_orchestration.py | 0 .../tests/test_connector/test_settings.py | 0 .../tests/test_main.py | 0 internal-enrichment/metras/VERSION | 1 - .../__metadata__/connector_config_schema.json | 116 ------------------ internal-enrichment/metras/docker-compose.yml | 29 ----- 27 files changed, 38 insertions(+), 164 deletions(-) rename internal-enrichment/{metras => metras-enrichment}/.dockerignore (100%) rename internal-enrichment/{metras => metras-enrichment}/Dockerfile (100%) rename internal-enrichment/{metras => metras-enrichment}/README.md (97%) rename internal-enrichment/{metras => metras-enrichment}/__metadata__/connector_manifest.json (88%) rename internal-enrichment/{metras => metras-enrichment}/config.yml.sample (85%) create mode 100644 internal-enrichment/metras-enrichment/docker-compose.yml rename internal-enrichment/{metras => metras-enrichment}/entrypoint.sh (100%) rename internal-enrichment/{metras => metras-enrichment}/pyproject.toml (100%) rename internal-enrichment/{metras => metras-enrichment}/src/connector/__init__.py (100%) rename internal-enrichment/{metras => metras-enrichment}/src/connector/connector.py (100%) rename internal-enrichment/{metras => metras-enrichment}/src/connector/converter_to_stix.py (89%) rename internal-enrichment/{metras => metras-enrichment}/src/connector/settings.py (79%) rename internal-enrichment/{metras => metras-enrichment}/src/connector/utils.py (100%) rename internal-enrichment/{metras => metras-enrichment}/src/main.py (100%) rename internal-enrichment/{metras => metras-enrichment}/src/metras_client/__init__.py (100%) rename internal-enrichment/{metras => metras-enrichment}/src/metras_client/api_client.py (100%) rename internal-enrichment/{metras => metras-enrichment}/src/requirements.txt (69%) rename internal-enrichment/{metras => metras-enrichment}/tests/conftest.py (100%) rename internal-enrichment/{metras => metras-enrichment}/tests/test-requirements.txt (100%) rename internal-enrichment/{metras => metras-enrichment}/tests/test_connector/test_client.py (100%) rename internal-enrichment/{metras => metras-enrichment}/tests/test_connector/test_converter.py (100%) rename internal-enrichment/{metras => metras-enrichment}/tests/test_connector/test_orchestration.py (100%) rename internal-enrichment/{metras => metras-enrichment}/tests/test_connector/test_settings.py (100%) rename internal-enrichment/{metras => metras-enrichment}/tests/test_main.py (100%) delete mode 100644 internal-enrichment/metras/VERSION delete mode 100644 internal-enrichment/metras/__metadata__/connector_config_schema.json delete mode 100644 internal-enrichment/metras/docker-compose.yml diff --git a/internal-enrichment/metras/.dockerignore b/internal-enrichment/metras-enrichment/.dockerignore similarity index 100% rename from internal-enrichment/metras/.dockerignore rename to internal-enrichment/metras-enrichment/.dockerignore diff --git a/internal-enrichment/metras/Dockerfile b/internal-enrichment/metras-enrichment/Dockerfile similarity index 100% rename from internal-enrichment/metras/Dockerfile rename to internal-enrichment/metras-enrichment/Dockerfile diff --git a/internal-enrichment/metras/README.md b/internal-enrichment/metras-enrichment/README.md similarity index 97% rename from internal-enrichment/metras/README.md rename to internal-enrichment/metras-enrichment/README.md index 5ab6e1f7558..b3aa24e53cc 100644 --- a/internal-enrichment/metras/README.md +++ b/internal-enrichment/metras-enrichment/README.md @@ -29,7 +29,7 @@ platform when you enrich an observable in OpenCTI. Adds a context **Note** (and - Playbook-compatible (`playbook_compatible=True`, `entity_in_scope()` guard). ## Requirements -- OpenCTI **7.260529.0** (`pycti==7.260529.0`). A Metras API key. +- OpenCTI **7.260722.0** (`pycti==7.260722.0`). A Metras API key. ## Configuration @@ -48,7 +48,7 @@ platform when you enrich an observable in OpenCTI. Adds a context **Note** (and ## Usage Right-click an IPv4 or file-hash observable → **Enrich** → *Metras-Enrichment*, or set `CONNECTOR_AUTO=true` to enrich automatically. Triggering via API uses -`stixCoreObjectEdit.askEnrichment(connectorId)` on OpenCTI 7.260529.0+. +`stixCoreObjectEdit.askEnrichment(connectorId)` on OpenCTI 7.260722.0+. ## Troubleshooting | Symptom | Cause / fix | diff --git a/internal-enrichment/metras/__metadata__/connector_manifest.json b/internal-enrichment/metras-enrichment/__metadata__/connector_manifest.json similarity index 88% rename from internal-enrichment/metras/__metadata__/connector_manifest.json rename to internal-enrichment/metras-enrichment/__metadata__/connector_manifest.json index 02c6647f0ab..bc41570575f 100644 --- a/internal-enrichment/metras/__metadata__/connector_manifest.json +++ b/internal-enrichment/metras-enrichment/__metadata__/connector_manifest.json @@ -12,11 +12,11 @@ "last_verified_date": null, "playbook_supported": true, "max_confidence_level": 50, - "support_version": ">=7.260529.0", + "support_version": ">=7.260722.0", "subscription_link": null, - "source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/internal-enrichment/metras", + "source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/internal-enrichment/metras-enrichment", "manager_supported": false, - "container_version": "1.0.0", + "container_version": "rolling", "container_image": "opencti/connector-metras-enrichment", "container_type": "INTERNAL_ENRICHMENT" } diff --git a/internal-enrichment/metras/config.yml.sample b/internal-enrichment/metras-enrichment/config.yml.sample similarity index 85% rename from internal-enrichment/metras/config.yml.sample rename to internal-enrichment/metras-enrichment/config.yml.sample index 21d8b1518a6..d409c44b2f1 100644 --- a/internal-enrichment/metras/config.yml.sample +++ b/internal-enrichment/metras-enrichment/config.yml.sample @@ -3,11 +3,10 @@ opencti: token: 'ChangeMe' connector: - type: 'INTERNAL_ENRICHMENT' id: 'ChangeMe' # UUIDv4 name: 'Metras-Enrichment' scope: 'IPv4-Addr,StixFile' - log_level: 'info' + log_level: 'error' auto: false metras: diff --git a/internal-enrichment/metras-enrichment/docker-compose.yml b/internal-enrichment/metras-enrichment/docker-compose.yml new file mode 100644 index 00000000000..286cacf65dc --- /dev/null +++ b/internal-enrichment/metras-enrichment/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3' +services: + connector-metras-enrichment: + image: opencti/connector-metras-enrichment:latest + environment: + - OPENCTI_URL=http://localhost + - OPENCTI_TOKEN=ChangeMe + - CONNECTOR_ID=ChangeMe + - CONNECTOR_NAME=Metras-Enrichment + - CONNECTOR_SCOPE=IPv4-Addr,StixFile + - CONNECTOR_LOG_LEVEL=error + - CONNECTOR_AUTO=false + - METRAS_API_BASE_URL=https://api.metras.sa/api + - METRAS_API_KEY=ChangeMe + - METRAS_VERIFY_SSL=true + - METRAS_MAX_TLP=amber+strict + restart: always diff --git a/internal-enrichment/metras/entrypoint.sh b/internal-enrichment/metras-enrichment/entrypoint.sh similarity index 100% rename from internal-enrichment/metras/entrypoint.sh rename to internal-enrichment/metras-enrichment/entrypoint.sh diff --git a/internal-enrichment/metras/pyproject.toml b/internal-enrichment/metras-enrichment/pyproject.toml similarity index 100% rename from internal-enrichment/metras/pyproject.toml rename to internal-enrichment/metras-enrichment/pyproject.toml diff --git a/internal-enrichment/metras/src/connector/__init__.py b/internal-enrichment/metras-enrichment/src/connector/__init__.py similarity index 100% rename from internal-enrichment/metras/src/connector/__init__.py rename to internal-enrichment/metras-enrichment/src/connector/__init__.py diff --git a/internal-enrichment/metras/src/connector/connector.py b/internal-enrichment/metras-enrichment/src/connector/connector.py similarity index 100% rename from internal-enrichment/metras/src/connector/connector.py rename to internal-enrichment/metras-enrichment/src/connector/connector.py diff --git a/internal-enrichment/metras/src/connector/converter_to_stix.py b/internal-enrichment/metras-enrichment/src/connector/converter_to_stix.py similarity index 89% rename from internal-enrichment/metras/src/connector/converter_to_stix.py rename to internal-enrichment/metras-enrichment/src/connector/converter_to_stix.py index 61fe29cc699..f51be9256a9 100644 --- a/internal-enrichment/metras/src/connector/converter_to_stix.py +++ b/internal-enrichment/metras-enrichment/src/connector/converter_to_stix.py @@ -5,12 +5,8 @@ Relationship linking them. No Sightings or Infrastructure objects are emitted. """ -import uuid - import stix2 -from pycti import Identity, OpenCTIConnectorHelper, StixCoreRelationship - -_NOTE_NAMESPACE = uuid.UUID("9b3c7d15-ca92-4d5e-8a4d-2e2c1b4e7a9b") +from pycti import Identity, Note, OpenCTIConnectorHelper, StixCoreRelationship class ConverterToStix: @@ -39,10 +35,8 @@ def create_note( content: str, labels: list | None = None, ) -> stix2.Note: - note_seed = f"metras-note-{observable_id}-{abstract}" - note_id = f"note--{uuid.uuid5(_NOTE_NAMESPACE, note_seed)}" return stix2.Note( - id=note_id, + id=Note.generate_id(created=None, content=content, abstract=abstract), abstract=abstract, content=content, object_refs=[observable_id], diff --git a/internal-enrichment/metras/src/connector/settings.py b/internal-enrichment/metras-enrichment/src/connector/settings.py similarity index 79% rename from internal-enrichment/metras/src/connector/settings.py rename to internal-enrichment/metras-enrichment/src/connector/settings.py index 98d20670cc3..c130655b1b7 100644 --- a/internal-enrichment/metras/src/connector/settings.py +++ b/internal-enrichment/metras-enrichment/src/connector/settings.py @@ -11,8 +11,14 @@ class InternalEnrichmentConnectorConfig(BaseInternalEnrichmentConnectorConfig): + id: str = Field( + default="f9d92e7f-3ee4-4a2a-a8e6-8b07766c66ab", + description="The unique identifier of the connector.", + examples=["f9d92e7f-3ee4-4a2a-a8e6-8b07766c66ab"], + ) name: str = Field( default="Metras-Enrichment", + description="The name of the connector.", examples=["Metras-Enrichment"], ) scope: ListFromString = Field( @@ -20,7 +26,11 @@ class InternalEnrichmentConnectorConfig(BaseInternalEnrichmentConnectorConfig): description="Entity types this connector enriches.", examples=[["IPv4-Addr", "StixFile"]], ) - auto: bool = Field(default=False, examples=[False]) + auto: bool = Field( + default=False, + description="Automatically enrich entities when they are created.", + examples=[False], + ) class MetrasConfig(BaseConfigModel): diff --git a/internal-enrichment/metras/src/connector/utils.py b/internal-enrichment/metras-enrichment/src/connector/utils.py similarity index 100% rename from internal-enrichment/metras/src/connector/utils.py rename to internal-enrichment/metras-enrichment/src/connector/utils.py diff --git a/internal-enrichment/metras/src/main.py b/internal-enrichment/metras-enrichment/src/main.py similarity index 100% rename from internal-enrichment/metras/src/main.py rename to internal-enrichment/metras-enrichment/src/main.py diff --git a/internal-enrichment/metras/src/metras_client/__init__.py b/internal-enrichment/metras-enrichment/src/metras_client/__init__.py similarity index 100% rename from internal-enrichment/metras/src/metras_client/__init__.py rename to internal-enrichment/metras-enrichment/src/metras_client/__init__.py diff --git a/internal-enrichment/metras/src/metras_client/api_client.py b/internal-enrichment/metras-enrichment/src/metras_client/api_client.py similarity index 100% rename from internal-enrichment/metras/src/metras_client/api_client.py rename to internal-enrichment/metras-enrichment/src/metras_client/api_client.py diff --git a/internal-enrichment/metras/src/requirements.txt b/internal-enrichment/metras-enrichment/src/requirements.txt similarity index 69% rename from internal-enrichment/metras/src/requirements.txt rename to internal-enrichment/metras-enrichment/src/requirements.txt index fb7e773723b..f800bd46cbb 100644 --- a/internal-enrichment/metras/src/requirements.txt +++ b/internal-enrichment/metras-enrichment/src/requirements.txt @@ -1,6 +1,6 @@ -pycti==7.260529.0 +pycti==7.260722.0 pydantic~=2.11.3 stix2>=3.0.1,<4.0.0 requests>=2.32.0,<3.0.0 pyyaml>=6.0.1,<7.0.0 -connectors-sdk @ git+https://github.com/OpenCTI-Platform/connectors.git@7.260529.0#subdirectory=connectors-sdk +connectors-sdk @ git+https://github.com/OpenCTI-Platform/connectors.git@master#subdirectory=connectors-sdk diff --git a/internal-enrichment/metras/tests/conftest.py b/internal-enrichment/metras-enrichment/tests/conftest.py similarity index 100% rename from internal-enrichment/metras/tests/conftest.py rename to internal-enrichment/metras-enrichment/tests/conftest.py diff --git a/internal-enrichment/metras/tests/test-requirements.txt b/internal-enrichment/metras-enrichment/tests/test-requirements.txt similarity index 100% rename from internal-enrichment/metras/tests/test-requirements.txt rename to internal-enrichment/metras-enrichment/tests/test-requirements.txt diff --git a/internal-enrichment/metras/tests/test_connector/test_client.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_client.py similarity index 100% rename from internal-enrichment/metras/tests/test_connector/test_client.py rename to internal-enrichment/metras-enrichment/tests/test_connector/test_client.py diff --git a/internal-enrichment/metras/tests/test_connector/test_converter.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_converter.py similarity index 100% rename from internal-enrichment/metras/tests/test_connector/test_converter.py rename to internal-enrichment/metras-enrichment/tests/test_connector/test_converter.py diff --git a/internal-enrichment/metras/tests/test_connector/test_orchestration.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py similarity index 100% rename from internal-enrichment/metras/tests/test_connector/test_orchestration.py rename to internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py diff --git a/internal-enrichment/metras/tests/test_connector/test_settings.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_settings.py similarity index 100% rename from internal-enrichment/metras/tests/test_connector/test_settings.py rename to internal-enrichment/metras-enrichment/tests/test_connector/test_settings.py diff --git a/internal-enrichment/metras/tests/test_main.py b/internal-enrichment/metras-enrichment/tests/test_main.py similarity index 100% rename from internal-enrichment/metras/tests/test_main.py rename to internal-enrichment/metras-enrichment/tests/test_main.py diff --git a/internal-enrichment/metras/VERSION b/internal-enrichment/metras/VERSION deleted file mode 100644 index 3eefcb9dd5b..00000000000 --- a/internal-enrichment/metras/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.0.0 diff --git a/internal-enrichment/metras/__metadata__/connector_config_schema.json b/internal-enrichment/metras/__metadata__/connector_config_schema.json deleted file mode 100644 index fb9d225afdd..00000000000 --- a/internal-enrichment/metras/__metadata__/connector_config_schema.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://www.filigran.io/connectors/w_config.schema.json", - "type": "object", - "properties": { - "OPENCTI_URL": { - "description": "The base URL of the OpenCTI instance.", - "format": "uri", - "maxLength": 2083, - "minLength": 1, - "type": "string" - }, - "OPENCTI_TOKEN": { - "description": "The API token to connect to OpenCTI.", - "type": "string" - }, - "CONNECTOR_NAME": { - "default": "Metras-Enrichment", - "examples": [ - "Metras-Enrichment" - ], - "type": "string" - }, - "CONNECTOR_SCOPE": { - "default": [ - "IPv4-Addr", - "StixFile" - ], - "description": "Entity types this connector enriches.", - "examples": [ - [ - "IPv4-Addr", - "StixFile" - ] - ], - "items": { - "type": "string" - }, - "type": "array" - }, - "CONNECTOR_LOG_LEVEL": { - "default": "error", - "description": "The minimum level of logs to display.", - "enum": [ - "debug", - "info", - "warn", - "warning", - "error" - ], - "type": "string" - }, - "CONNECTOR_TYPE": { - "const": "INTERNAL_ENRICHMENT", - "default": "INTERNAL_ENRICHMENT", - "type": "string" - }, - "CONNECTOR_AUTO": { - "default": false, - "examples": [ - false - ], - "type": "boolean" - }, - "METRAS_API_BASE_URL": { - "default": "https://api.metras.sa/api", - "description": "Base URL of the Metras API.", - "examples": [ - "https://api.metras.sa/api" - ], - "format": "uri", - "maxLength": 2083, - "minLength": 1, - "type": "string" - }, - "METRAS_API_KEY": { - "description": "Metras API key (X-API-KEY header).", - "examples": [ - "ChangeMe" - ], - "format": "password", - "type": "string", - "writeOnly": true - }, - "METRAS_VERIFY_SSL": { - "default": true, - "description": "Verify TLS certificates.", - "examples": [ - true - ], - "type": "boolean" - }, - "METRAS_MAX_TLP": { - "description": "Maximum TLP level the connector will enrich.", - "enum": [ - "clear", - "white", - "green", - "amber", - "amber+strict", - "red" - ], - "type": "string", - "default": "amber+strict", - "examples": [ - "amber+strict" - ] - } - }, - "required": [ - "OPENCTI_URL", - "OPENCTI_TOKEN", - "METRAS_API_KEY" - ], - "additionalProperties": true -} \ No newline at end of file diff --git a/internal-enrichment/metras/docker-compose.yml b/internal-enrichment/metras/docker-compose.yml deleted file mode 100644 index f8076a2663b..00000000000 --- a/internal-enrichment/metras/docker-compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - connector-metras-enrichment: - build: - context: . - dockerfile: Dockerfile - image: opencti/connector-metras-enrichment:1.0.0 - environment: - - OPENCTI_URL=http://opencti:8080 - - OPENCTI_TOKEN=${OPENCTI_ADMIN_TOKEN} - - CONNECTOR_ID=${CONNECTOR_METRAS_ENRICHMENT_ID} - - CONNECTOR_TYPE=INTERNAL_ENRICHMENT - - CONNECTOR_NAME=Metras-Enrichment - - CONNECTOR_SCOPE=IPv4-Addr,StixFile - - CONNECTOR_LOG_LEVEL=info - - CONNECTOR_AUTO=false - - METRAS_API_BASE_URL=https://api.metras.sa/api - - METRAS_API_KEY=${METRAS_API_KEY} - - METRAS_VERIFY_SSL=true - - METRAS_MAX_TLP=amber+strict - restart: always - cap_drop: - - ALL - security_opt: - - no-new-privileges:true - read_only: true - tmpfs: - - /tmp - mem_limit: 512m - pids_limit: 100 From 311d45dea664e8e93b57089f1af9e9d619bc2f35 Mon Sep 17 00:00:00 2001 From: Khidr6g <270672697+Khidr6G@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:18:04 +0300 Subject: [PATCH 7/8] fix(metras): pin pycti to 7.260728.0 to match connectors-sdk master (#7042) --- internal-enrichment/metras-enrichment/README.md | 4 ++-- internal-enrichment/metras-enrichment/src/requirements.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal-enrichment/metras-enrichment/README.md b/internal-enrichment/metras-enrichment/README.md index b3aa24e53cc..b97377803c9 100644 --- a/internal-enrichment/metras-enrichment/README.md +++ b/internal-enrichment/metras-enrichment/README.md @@ -29,7 +29,7 @@ platform when you enrich an observable in OpenCTI. Adds a context **Note** (and - Playbook-compatible (`playbook_compatible=True`, `entity_in_scope()` guard). ## Requirements -- OpenCTI **7.260722.0** (`pycti==7.260722.0`). A Metras API key. +- OpenCTI **7.260728.0** (`pycti==7.260728.0`). A Metras API key. ## Configuration @@ -48,7 +48,7 @@ platform when you enrich an observable in OpenCTI. Adds a context **Note** (and ## Usage Right-click an IPv4 or file-hash observable → **Enrich** → *Metras-Enrichment*, or set `CONNECTOR_AUTO=true` to enrich automatically. Triggering via API uses -`stixCoreObjectEdit.askEnrichment(connectorId)` on OpenCTI 7.260722.0+. +`stixCoreObjectEdit.askEnrichment(connectorId)` on OpenCTI 7.260728.0+. ## Troubleshooting | Symptom | Cause / fix | diff --git a/internal-enrichment/metras-enrichment/src/requirements.txt b/internal-enrichment/metras-enrichment/src/requirements.txt index f800bd46cbb..9d65391778f 100644 --- a/internal-enrichment/metras-enrichment/src/requirements.txt +++ b/internal-enrichment/metras-enrichment/src/requirements.txt @@ -1,4 +1,4 @@ -pycti==7.260722.0 +pycti==7.260728.0 pydantic~=2.11.3 stix2>=3.0.1,<4.0.0 requests>=2.32.0,<3.0.0 From c96343d5ee5e09d5d3a57f79f90981a64359a5cd Mon Sep 17 00:00:00 2001 From: Khidr6g <270672697+Khidr6G@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:58:59 +0300 Subject: [PATCH 8/8] =?UTF-8?q?fix(metras):=20make=20enrichment=20playbook?= =?UTF-8?q?-compatible=20=E2=80=94=20return=20original=20bundle=20on=20all?= =?UTF-8?q?=20failure=20paths=20(#7042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/connector/connector.py | 71 ++++++++++++------- .../test_connector/test_orchestration.py | 37 +++++++--- 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/internal-enrichment/metras-enrichment/src/connector/connector.py b/internal-enrichment/metras-enrichment/src/connector/connector.py index d3a08591964..8e2627cd71c 100644 --- a/internal-enrichment/metras-enrichment/src/connector/connector.py +++ b/internal-enrichment/metras-enrichment/src/connector/connector.py @@ -114,28 +114,42 @@ def _resolve_stix_id(data, stix_entity) -> str | None: enrichment_entity = data.get("enrichment_entity") or {} return enrichment_entity.get("standard_id") or stix_entity.get("id") - def process_message(self, data: dict) -> str | None: + def _send_bundle(self, stix_objects: list) -> list: + """Serialize and send a STIX bundle. Empty input is a no-op.""" + if not stix_objects: + return [] + bundle = self.helper.stix2_create_bundle(stix_objects) + return self.helper.send_stix2_bundle(bundle, cleanup_inconsistent_bundle=True) + + def process_message(self, data: dict) -> str: + # Playbook-compatible (playbook_compatible=True): the original bundle is + # returned on every no-enrichment / failure path so a playbook can continue + # to its next step instead of breaking. + stix_objects = data.get("stix_objects") or [] + stix_entity = data.get("stix_entity") or {} + obs_type = (stix_entity.get("type") or "").lower() + obs_id = self._resolve_stix_id(data, stix_entity) + try: - stix_entity = data.get("stix_entity") or {} - stix_objects = data.get("stix_objects", []) - obs_type = (stix_entity.get("type") or "").lower() - obs_id = self._resolve_stix_id(data, stix_entity) if not obs_id: - raise ValueError( - "[CONNECTOR] Could not resolve a STIX id for the entity " - "(no standard_id or stix_entity id); aborting." - ) + self._send_bundle(stix_objects) + return "[CONNECTOR] No STIX id resolved; original bundle returned" level = self._get_tlp_level(stix_entity) if level and not self._tlp_allowed(level): - self.helper.connector_logger.warning( + self.helper.connector_logger.info( "[CONNECTOR] Skipped: TLP exceeds max", meta={"tlp": level, "max": self._max_tlp.value}, ) - return f"[CONNECTOR] Skipped: TLP {level} exceeds max {self._max_tlp.value}" + self._send_bundle(stix_objects) + return ( + f"[CONNECTOR] Skipped: TLP {level} exceeds max " + f"{self._max_tlp.value}; original bundle returned" + ) if not self.entity_in_scope(obs_type): - return f"[CONNECTOR] {obs_type} not in scope" + self._send_bundle(stix_objects) + return f"[CONNECTOR] {obs_type} not in scope; original bundle returned" self._errors, self._successes = [], 0 handlers = { @@ -145,29 +159,38 @@ def process_message(self, data: dict) -> str | None: } handler = handlers.get(obs_type) if not handler: - return f"[CONNECTOR] Unsupported type {obs_type}" + self._send_bundle(stix_objects) + return ( + f"[CONNECTOR] Unsupported type {obs_type}; original bundle returned" + ) new_objects = handler(stix_entity, obs_id) if self._successes == 0 and self._errors: - first = self._errors[0] - raise ValueError(f"[CONNECTOR] All Metras lookups failed: {first}") + # Every external lookup failed — surface it in the logs but still + # pass the original bundle through so the playbook is not broken. + self.helper.connector_logger.error( + "[CONNECTOR] All Metras lookups failed", + meta={"error": self._errors[0]}, + ) + self._send_bundle(stix_objects) + return "[CONNECTOR] All Metras lookups failed; original bundle returned" if new_objects: - bundle_objects = ( - stix_objects + [self.converter.author_object()] + new_objects - ) - bundle = self.helper.stix2_create_bundle(bundle_objects) - sent = self.helper.send_stix2_bundle( - bundle, cleanup_inconsistent_bundle=True - ) + enriched = stix_objects + [self.converter.author_object()] + new_objects + sent = self._send_bundle(enriched) return f"[CONNECTOR] Sent {len(sent)} bundle(s)" - return "[CONNECTOR] No Metras fleet data found for this observable" + + self._send_bundle(stix_objects) + return "[CONNECTOR] No Metras fleet data found; original bundle returned" except Exception as err: # noqa: BLE001 self.helper.connector_logger.error( "[CONNECTOR] Error", meta={"error": str(err)} ) - raise + self._send_bundle(stix_objects) + return ( + f"[CONNECTOR] Error during enrichment ({err}); original bundle returned" + ) # ------------------------------------------------------------------ # def _enrich_ipv4(self, stix_entity: dict, obs_id: str) -> list: diff --git a/internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py index a864799433d..96ca2565374 100644 --- a/internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py +++ b/internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py @@ -3,7 +3,6 @@ from types import SimpleNamespace from unittest.mock import MagicMock -import pytest from connector.connector import MetrasEnrichmentConnector from connectors_sdk.models.enums import TLPLevel from pydantic import SecretStr @@ -45,10 +44,28 @@ def test_file_alias_is_in_scope(): assert conn.entity_in_scope("file") is True # file -> stixfile alias -def test_missing_stix_id_aborts(): +def test_missing_stix_id_returns_original_bundle(): + # Playbook-compatible: a missing STIX id is a no-enrichment path, not a crash. conn, _ = _enr() - with pytest.raises(ValueError): - conn.process_message({"stix_entity": {"type": "IPv4-Addr", "value": "1.2.3.4"}}) + msg = conn.process_message( + {"stix_entity": {"type": "IPv4-Addr", "value": "1.2.3.4"}} + ) + assert "original bundle returned" in msg + + +def test_out_of_scope_forwards_original_bundle(): + # The original bundle must be passed through unchanged when out of scope. + conn, helper = _enr() + original = [{"type": "domain-name", "id": "domain-name--x", "value": "e.com"}] + msg = conn.process_message( + { + "stix_objects": original, + "stix_entity": {"type": "Domain-Name", "id": "domain-name--x"}, + } + ) + assert "not in scope" in msg and "original bundle returned" in msg + helper.stix2_create_bundle.assert_called_once() + assert helper.stix2_create_bundle.call_args[0][0] == original def test_tlp_gate_blocks_above_max(): @@ -112,15 +129,17 @@ def test_file_hit_builds_note_and_system_and_sends_bundle(): assert "note" in types and "identity" in types and "relationship" in types -def test_all_lookups_fail_raises(): +def test_all_lookups_fail_returns_original_bundle(): + # When every external lookup fails, the connector logs the error but still + # passes the original bundle through (playbook-safe) instead of raising. conn, _ = _enr() boom = MagicMock(side_effect=Exception("x")) conn.client.alerts_by_agent_ip = boom conn.client.list_endpoints = boom - with pytest.raises(ValueError): - conn.process_message( - {"stix_entity": {"type": "IPv4-Addr", "id": IPV4_ID, "value": "10.0.0.1"}} - ) + msg = conn.process_message( + {"stix_entity": {"type": "IPv4-Addr", "id": IPV4_ID, "value": "10.0.0.1"}} + ) + assert "All Metras lookups failed" in msg def test_empty_ipv4_value_skips_lookup():