diff --git a/internal-enrichment/metras-enrichment/.dockerignore b/internal-enrichment/metras-enrichment/.dockerignore new file mode 100644 index 00000000000..33cdfff0093 --- /dev/null +++ b/internal-enrichment/metras-enrichment/.dockerignore @@ -0,0 +1,11 @@ +__metadata__ +**/__pycache__ +**/__docs__ +**/.venv +**/venv +**/logs +**/config.yml +**/*.egg-info +**/*.gql +.plan +tests diff --git a/internal-enrichment/metras-enrichment/Dockerfile b/internal-enrichment/metras-enrichment/Dockerfile new file mode 100644 index 00000000000..936756655a8 --- /dev/null +++ b/internal-enrichment/metras-enrichment/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-enrichment/README.md b/internal-enrichment/metras-enrichment/README.md new file mode 100644 index 00000000000..b97377803c9 --- /dev/null +++ b/internal-enrichment/metras-enrichment/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.260728.0** (`pycti==7.260728.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 | `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 | +| `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.260728.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-enrichment/__metadata__/connector_manifest.json b/internal-enrichment/metras-enrichment/__metadata__/connector_manifest.json new file mode 100644 index 00000000000..bc41570575f --- /dev/null +++ b/internal-enrichment/metras-enrichment/__metadata__/connector_manifest.json @@ -0,0 +1,22 @@ +{ + "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 presence lookups", + "logo": null, + "use_cases": ["Detection & Response Enablement"], + "solution_categories": ["Endpoint Detection & Response"], + "contact": null, + "license_type": "Commercial", + "verified": false, + "last_verified_date": null, + "playbook_supported": true, + "max_confidence_level": 50, + "support_version": ">=7.260722.0", + "subscription_link": null, + "source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/internal-enrichment/metras-enrichment", + "manager_supported": false, + "container_version": "rolling", + "container_image": "opencti/connector-metras-enrichment", + "container_type": "INTERNAL_ENRICHMENT" +} diff --git a/internal-enrichment/metras-enrichment/config.yml.sample b/internal-enrichment/metras-enrichment/config.yml.sample new file mode 100644 index 00000000000..d409c44b2f1 --- /dev/null +++ b/internal-enrichment/metras-enrichment/config.yml.sample @@ -0,0 +1,16 @@ +opencti: + url: 'http://localhost:8080' + token: 'ChangeMe' + +connector: + id: 'ChangeMe' # UUIDv4 + name: 'Metras-Enrichment' + scope: 'IPv4-Addr,StixFile' + log_level: 'error' + 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-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-enrichment/entrypoint.sh b/internal-enrichment/metras-enrichment/entrypoint.sh new file mode 100644 index 00000000000..db0792b72fa --- /dev/null +++ b/internal-enrichment/metras-enrichment/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd /opt/opencti-connector-metras-enrichment +exec python3 main.py diff --git a/internal-enrichment/metras-enrichment/pyproject.toml b/internal-enrichment/metras-enrichment/pyproject.toml new file mode 100644 index 00000000000..1a5498cf328 --- /dev/null +++ b/internal-enrichment/metras-enrichment/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-enrichment/src/connector/__init__.py b/internal-enrichment/metras-enrichment/src/connector/__init__.py new file mode 100644 index 00000000000..5b66c8191ff --- /dev/null +++ b/internal-enrichment/metras-enrichment/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-enrichment/src/connector/connector.py b/internal-enrichment/metras-enrichment/src/connector/connector.py new file mode 100644 index 00000000000..8e2627cd71c --- /dev/null +++ b/internal-enrichment/metras-enrichment/src/connector/connector.py @@ -0,0 +1,302 @@ +"""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", + meta={"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", + meta={"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 _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: + if not obs_id: + 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.info( + "[CONNECTOR] Skipped: TLP exceeds max", + meta={"tlp": level, "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): + self._send_bundle(stix_objects) + return f"[CONNECTOR] {obs_type} not in scope; original bundle returned" + + 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: + 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: + # 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: + enriched = stix_objects + [self.converter.author_object()] + new_objects + sent = self._send_bundle(enriched) + return f"[CONNECTOR] Sent {len(sent)} bundle(s)" + + 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)} + ) + 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: + 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 = [] + + 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") or "?" 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") or "?" 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-enrichment/src/connector/converter_to_stix.py b/internal-enrichment/metras-enrichment/src/connector/converter_to_stix.py new file mode 100644 index 00000000000..f51be9256a9 --- /dev/null +++ b/internal-enrichment/metras-enrichment/src/connector/converter_to_stix.py @@ -0,0 +1,76 @@ +"""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 stix2 +from pycti import Identity, Note, OpenCTIConnectorHelper, StixCoreRelationship + + +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: + return stix2.Note( + id=Note.generate_id(created=None, content=content, abstract=abstract), + 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-enrichment/src/connector/settings.py b/internal-enrichment/metras-enrichment/src/connector/settings.py new file mode 100644 index 00000000000..c130655b1b7 --- /dev/null +++ b/internal-enrichment/metras-enrichment/src/connector/settings.py @@ -0,0 +1,62 @@ +"""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): + 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( + default=["IPv4-Addr", "StixFile"], + description="Entity types this connector enriches.", + examples=[["IPv4-Addr", "StixFile"]], + ) + auto: bool = Field( + default=False, + description="Automatically enrich entities when they are created.", + 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-enrichment/src/connector/utils.py b/internal-enrichment/metras-enrichment/src/connector/utils.py new file mode 100644 index 00000000000..c0db2035b04 --- /dev/null +++ b/internal-enrichment/metras-enrichment/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 | None) -> str: + """Remove common defanging so values match the external API.""" + if not value: + return "" + 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-enrichment/src/main.py b/internal-enrichment/metras-enrichment/src/main.py new file mode 100644 index 00000000000..31c89f519db --- /dev/null +++ b/internal-enrichment/metras-enrichment/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-enrichment/src/metras_client/__init__.py b/internal-enrichment/metras-enrichment/src/metras_client/__init__.py new file mode 100644 index 00000000000..9421cb9c4ab --- /dev/null +++ b/internal-enrichment/metras-enrichment/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-enrichment/src/metras_client/api_client.py b/internal-enrichment/metras-enrichment/src/metras_client/api_client.py new file mode 100644 index 00000000000..6eb91f40736 --- /dev/null +++ b/internal-enrichment/metras-enrichment/src/metras_client/api_client.py @@ -0,0 +1,262 @@ +"""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", + meta={"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 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) + + 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-enrichment/src/requirements.txt b/internal-enrichment/metras-enrichment/src/requirements.txt new file mode 100644 index 00000000000..9d65391778f --- /dev/null +++ b/internal-enrichment/metras-enrichment/src/requirements.txt @@ -0,0 +1,6 @@ +pycti==7.260728.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@master#subdirectory=connectors-sdk diff --git a/internal-enrichment/metras-enrichment/tests/conftest.py b/internal-enrichment/metras-enrichment/tests/conftest.py new file mode 100644 index 00000000000..d956842a2b5 --- /dev/null +++ b/internal-enrichment/metras-enrichment/tests/conftest.py @@ -0,0 +1,4 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) diff --git a/internal-enrichment/metras-enrichment/tests/test-requirements.txt b/internal-enrichment/metras-enrichment/tests/test-requirements.txt new file mode 100644 index 00000000000..6ef4498bfff --- /dev/null +++ b/internal-enrichment/metras-enrichment/tests/test-requirements.txt @@ -0,0 +1,2 @@ +-r ../src/requirements.txt +pytest>=8.0.0 diff --git a/internal-enrichment/metras-enrichment/tests/test_connector/test_client.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_client.py new file mode 100644 index 00000000000..cc5498b8158 --- /dev/null +++ b/internal-enrichment/metras-enrichment/tests/test_connector/test_client.py @@ -0,0 +1,79 @@ +"""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_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( + [ + 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-enrichment/tests/test_connector/test_converter.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_converter.py new file mode 100644 index 00000000000..43d4f072843 --- /dev/null +++ b/internal-enrichment/metras-enrichment/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 diff --git a/internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py new file mode 100644 index 00000000000..96ca2565374 --- /dev/null +++ b/internal-enrichment/metras-enrichment/tests/test_connector/test_orchestration.py @@ -0,0 +1,155 @@ +"""Orchestration tests for the Enrichment connector (helper + client mocked).""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +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" +FILE_ID = "file--22222222-2222-4222-8222-222222222222" +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_returns_original_bundle(): + # Playbook-compatible: a missing STIX id is a no-enrichment path, not a crash. + conn, _ = _enr() + 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(): + 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_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_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 + 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(): + # 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() diff --git a/internal-enrichment/metras-enrichment/tests/test_connector/test_settings.py b/internal-enrichment/metras-enrichment/tests/test_connector/test_settings.py new file mode 100644 index 00000000000..810a5e6a216 --- /dev/null +++ b/internal-enrichment/metras-enrichment/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-enrichment/tests/test_main.py b/internal-enrichment/metras-enrichment/tests/test_main.py new file mode 100644 index 00000000000..d50e81ee55e --- /dev/null +++ b/internal-enrichment/metras-enrichment/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/")