Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions external-import/metras-feed/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
__metadata__
**/__pycache__
**/__docs__
**/.venv
**/venv
**/logs
**/config.yml
**/*.egg-info
**/*.gql
.plan
tests
22 changes: 22 additions & 0 deletions external-import/metras-feed/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
FROM python:3.12-alpine

ENV CONNECTOR_TYPE=EXTERNAL_IMPORT
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY src /opt/opencti-connector-metras-feed

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-feed && \
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-feed
USER connector

ENTRYPOINT ["/entrypoint.sh"]
70 changes: 70 additions & 0 deletions external-import/metras-feed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# OpenCTI Metras Feed Connector (EXTERNAL_IMPORT)

Imports telemetry from the [Metras](https://dashboard.metras.sa/) security platform
(`api.metras.sa`) into OpenCTI on a schedule: EDR alerts (with MITRE ATT&CK mapping),
file binaries, and endpoint inventory.

## What it imports

| Metras source | OpenCTI output |
|---|---|
| EDR alerts (`/v1/edr/alerts`) | **Incident** (name, severity, score, labels) |
| └ `mitre_ids` | **Attack-Pattern** (keyed by `external_id`, merges with MITRE ATT&CK) + `uses` relationship |
| └ `url` | **Url** observable + `related-to` (external destination — a real IOC) |
| └ `endpoint_name` (+ `agent_ip`) | **Identity** (`identity_class: system`) + `related-to` |
| Binaries (`/v1/edr/binary/list`) | **StixFile** (MD5 / SHA-1 / SHA-256, name, size, score) + `related-to` System |
| Endpoints (`/v1/endpoints`) | **Identity** (`identity_class: system`); interface/tunnel IPs in the description |

> **Internal assets are System identities, not IOCs** — your fleet endpoints (and their internal
> IPs: `agent_ip`, interface/tunnel IPs) are modelled as `Identity(identity_class="system")` with the
> IPs in the description, **not** as IPv4-Addr observables. Only genuinely external artifacts (alert
> `url`, file hashes) become observables. (Pattern per OpenCTI maintainer review, PR #6164.)
>
> **Observables only** — no STIX Indicators are auto-created from binaries (analysts promote manually).
> Process name/GUID from alerts is folded into the Incident description (STIX 2.1 `Process` has no `name`).

## Incremental behavior
- **EDR alerts** have no time filter on the API → the connector filters client-side on
`last_occurrence_time` against stored state (`alerts_last_occurrence`).
- **Binaries** use the server-side `fromTime` window (state `binaries_last_seen`).
- **Endpoints** are re-listed each run (deduped by deterministic STIX IDs in OpenCTI).
- A category that errors does not advance its cursor (no data loss; safe retry next cycle).

## Requirements
- OpenCTI **7.260728.0** (pinned; `pycti==7.260728.0`).
- A Metras API key (`X-API-KEY`).

## Configuration

| Env var | Required | Default | Description |
|---|---|---|---|
| `OPENCTI_URL` | yes | — | OpenCTI base URL |
| `OPENCTI_TOKEN` | yes | — | OpenCTI API token |
| `CONNECTOR_ID` | yes | — | UUIDv4 for this connector |
| `CONNECTOR_NAME` | no | `Metras-Feed` | Connector name |
| `CONNECTOR_SCOPE` | no | `Metras` | Import scope |
| `CONNECTOR_LOG_LEVEL` | no | `error` | Log level |
| `CONNECTOR_DURATION_PERIOD` | no | `PT1H` | ISO-8601 poll interval |
| `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_IMPORT_ALERTS` | no | `true` | Import EDR alerts |
| `METRAS_IMPORT_BINARIES` | no | `true` | Import binaries |
| `METRAS_IMPORT_ENDPOINTS` | no | `true` | Import endpoints |
| `METRAS_BINARY_MALICIOUS_ONLY` | no | `true` | Only import banned/unsigned binaries |
| `METRAS_PAGE_SIZE` | no | `50` | Records per page |
| `METRAS_TLP_LEVEL` | no | `amber` | TLP marking (`clear`/`white`/`green`/`amber`/`red`) |

## Installation
```bash
cp config.yml.sample src/config.yml # or use env vars / docker-compose
docker compose up -d
```

## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `Metras API ping failed at startup` then exit | Bad `METRAS_API_KEY` or unreachable base URL |
| `Imported 0 incidents` after first run | Normal — alerts already imported; cursor advanced |
| `Cannot query field "s3"` | pycti/platform mismatch — keep `pycti==7.260728.0` |
| No Works in OpenCTI UI | Check `OPENCTI_TOKEN` permissions (Work API) |
22 changes: 22 additions & 0 deletions external-import/metras-feed/__metadata__/connector_manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"title": "Metras Feed Connector",
"slug": "metras-feed",
"description": "Imports Metras EDR alerts (with MITRE ATT&CK mapping), file binaries, and endpoint inventory into OpenCTI as STIX incidents, observables and system identities.",
"short_description": "External import connector for Metras EDR telemetry",
"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": false,
"max_confidence_level": 50,
"support_version": ">=7.260722.0",
"subscription_link": null,
"source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/metras-feed",
"manager_supported": false,
"container_version": "rolling",
"container_image": "opencti/connector-metras-feed",
"container_type": "EXTERNAL_IMPORT"
}
21 changes: 21 additions & 0 deletions external-import/metras-feed/config.yml.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
opencti:
url: 'http://localhost:8080'
token: 'ChangeMe'

connector:
id: 'ChangeMe' # UUIDv4
name: 'Metras-Feed'
scope: 'Metras'
log_level: 'error'
duration_period: 'PT1H' # ISO-8601: poll interval

metras:
api_base_url: 'https://api.metras.sa/api'
api_key: 'ChangeMe'
verify_ssl: true
import_alerts: true
import_binaries: true
import_endpoints: true
binary_malicious_only: true # only banned/unsigned binaries
page_size: 50
tlp_level: 'amber'
22 changes: 22 additions & 0 deletions external-import/metras-feed/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
version: '3'
services:
connector-metras-feed:
image: opencti/connector-metras-feed:latest
environment:
- OPENCTI_URL=http://localhost
- OPENCTI_TOKEN=ChangeMe
- CONNECTOR_ID=ChangeMe
- CONNECTOR_NAME=Metras-Feed
- CONNECTOR_SCOPE=Metras
- CONNECTOR_LOG_LEVEL=error
- CONNECTOR_DURATION_PERIOD=PT1H
- METRAS_API_BASE_URL=https://api.metras.sa/api
- METRAS_API_KEY=ChangeMe
- METRAS_VERIFY_SSL=true
- METRAS_IMPORT_ALERTS=true
- METRAS_IMPORT_BINARIES=true
- METRAS_IMPORT_ENDPOINTS=true
- METRAS_BINARY_MALICIOUS_ONLY=true
- METRAS_PAGE_SIZE=50
- METRAS_TLP_LEVEL=amber
restart: always
3 changes: 3 additions & 0 deletions external-import/metras-feed/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/sh
cd /opt/opencti-connector-metras-feed
exec python3 main.py
5 changes: 5 additions & 0 deletions external-import/metras-feed/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[tool.coverage.run]
source = ["connector", "metras_client"]

[tool.coverage.report]
show_missing = true
4 changes: 4 additions & 0 deletions external-import/metras-feed/src/connector/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from connector.connector import MetrasFeedConnector
from connector.settings import ConnectorSettings

__all__ = ["MetrasFeedConnector", "ConnectorSettings"]
207 changes: 207 additions & 0 deletions external-import/metras-feed/src/connector/connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"""Metras Feed connector (EXTERNAL_IMPORT).

Polls EDR alerts, binaries and endpoints from Metras and imports them into
OpenCTI as STIX. Incremental: alerts filter client-side on last_occurrence_time
(no fromTime param), binaries use server-side fromTime windowing.
"""

import sys
from datetime import datetime, timezone
from typing import TYPE_CHECKING

from connector.converter_to_stix import ConverterToStix
from connector.utils import is_newer_than, normalize_timestamp, stix_timestamp
from metras_client import MetrasAPIError, MetrasClient
from pycti import OpenCTIConnectorHelper

if TYPE_CHECKING:
from connector.settings import ConnectorSettings


class MetrasFeedConnector:
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, tlp_level=cfg.tlp_level)
self.cfg = cfg

# ------------------------------------------------------------------ #
def run(self) -> None:
"""Start the connector and schedule its runs via the pycti scheduler."""
self.helper.schedule_iso(
message_callback=self.process,
duration_period=self.config.connector.duration_period, # type: ignore[arg-type]
)

def process(self) -> None:
"""Run a single import cycle (invoked by the scheduler each period)."""
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)},
)
sys.exit(1)

self.helper.connector_logger.info(
"[CONNECTOR] Starting Metras Feed import cycle"
)
try:
self._import_data()
except (KeyboardInterrupt, SystemExit):
self.helper.connector_logger.info(
"[CONNECTOR] Connector stopped by user or system"
)
sys.exit(0)
except Exception as exc: # noqa: BLE001
self.helper.connector_logger.error(
"[CONNECTOR] An unexpected error occurred during the import cycle",
meta={"error": str(exc)},
)

# ------------------------------------------------------------------ #
Comment on lines +37 to +75

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# ------------------------------------------------------------------ #
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)},
)
sys.exit(1)
self.helper.connector_logger.info(
"[CONNECTOR] Starting Metras Feed import loop",
meta={"interval_seconds": self._interval},
)
while True:
try:
self._import_data()
except (KeyboardInterrupt, SystemExit):
self.helper.connector_logger.info("[CONNECTOR] Stopping")
break
except Exception as exc: # noqa: BLE001 - keep the loop alive
self.helper.connector_logger.error(
"[CONNECTOR] Import cycle crashed", meta={"error": str(exc)}
)
time.sleep(self._interval)
# ------------------------------------------------------------------ #
def run(self) -> None:
"""Start the connector and schedule its runs."""
self.helper.schedule_iso(
message_callback=self.process,
duration_period=self.config.connector.duration_period, # type: ignore[arg-type]
)
def process(self) -> None:
"""Process a single import cycle."""
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)},
)
sys.exit(1)
self.helper.connector_logger.info(
"[CONNECTOR] Starting Metras Feed import loop"
)
try:
self._import_data()
except (KeyboardInterrupt, SystemExit):
self.helper.connector_logger.info(
"[CONNECTOR] Connector stopped by user or system"
)
sys.exit(0)
except Exception as exc:
self.helper.connector_logger.error(
"[CONNECTOR] An unexpected error occurred, see connector logs for details",
meta={"error": str(exc)},
)

Please use the official scheduler from pycti instead of custom while loop.

def _import_data(self) -> None:
state = self.helper.get_state() or {}
alerts_cursor = normalize_timestamp(state.get("alerts_last_occurrence"))
binaries_cursor = state.get("binaries_last_seen") # ISO string for fromTime

now_iso = stix_timestamp(datetime.now(timezone.utc))
friendly = f"Metras Feed import @ {now_iso}"
work_id = self.helper.api.work.initiate_work(self.helper.connect_id, friendly)

all_objects = [
self.converter.author_object(),
self.converter.marking_object(),
]
new_alerts_max = alerts_cursor
new_binaries_max = binaries_cursor
counts = {"alerts": 0, "binaries": 0, "endpoints": 0}
errors = []
failed: set[str] = set()

# --- EDR alerts (client-side incremental) ---
if self.cfg.import_alerts:
try:
for alert in self.client.iter_edr_alerts(page_size=self.cfg.page_size):
ts = alert.get("last_occurrence_time")
if not is_newer_than(ts, alerts_cursor):
continue
objs = self.converter.process_alert(alert)
if objs:
all_objects.extend(objs)
counts["alerts"] += 1
parsed = normalize_timestamp(ts)
if parsed and (new_alerts_max is None or parsed > new_alerts_max):
new_alerts_max = parsed
self.helper.connector_logger.info(
"[CONNECTOR] Alerts processed",
meta={"new_incidents": counts["alerts"]},
)
except MetrasAPIError as exc:
errors.append(f"alerts: {exc}")
failed.add("alerts")
self.helper.connector_logger.error(
"[CONNECTOR] Alert import failed", meta={"error": str(exc)}
)

# --- Binaries (server-side fromTime window) ---
if self.cfg.import_binaries:
try:
for binary in self.client.iter_binaries(
from_time=binaries_cursor, page_size=self.cfg.page_size
):
objs = self.converter.process_binary(
binary, malicious_only=self.cfg.binary_malicious_only
)
if objs:
all_objects.extend(objs)
counts["binaries"] += 1
last_seen = binary.get("last_seen")
if last_seen and (
new_binaries_max is None or last_seen > new_binaries_max
):
new_binaries_max = last_seen
self.helper.connector_logger.info(
"[CONNECTOR] Binaries processed",
meta={"new_files": counts["binaries"]},
)
except MetrasAPIError as exc:
errors.append(f"binaries: {exc}")
failed.add("binaries")
self.helper.connector_logger.error(
"[CONNECTOR] Binary import failed", meta={"error": str(exc)}
)

# --- Endpoints (full inventory each run) ---
if self.cfg.import_endpoints:
try:
payload = self.client.list_endpoints()
for endpoint in payload.get("endpoints") or []:
objs = self.converter.process_endpoint(endpoint)
if objs:
all_objects.extend(objs)
counts["endpoints"] += 1
self.helper.connector_logger.info(
"[CONNECTOR] Endpoints processed",
meta={"endpoints": counts["endpoints"]},
)
except MetrasAPIError as exc:
errors.append(f"endpoints: {exc}")
failed.add("endpoints")
self.helper.connector_logger.error(
"[CONNECTOR] Endpoint import failed", meta={"error": str(exc)}
)

total = counts["alerts"] + counts["binaries"] + counts["endpoints"]

# Total failure: every enabled category errored and nothing produced.
if errors and total == 0:
msg = "Metras import failed: " + "; ".join(errors)
self.helper.api.work.to_processed(work_id, msg, in_error=True)
self.helper.connector_logger.error(
"[CONNECTOR] Import cycle failed", meta={"msg": msg}
)
return

if len(all_objects) > 2: # more than just the author + TLP marking
bundle = self.helper.stix2_create_bundle(all_objects)
self.helper.send_stix2_bundle(
bundle, work_id=work_id, cleanup_inconsistent_bundle=True
)
msg = (
f"Imported {counts['alerts']} incidents, {counts['binaries']} files, "
f"{counts['endpoints']} endpoints ({len(all_objects)} STIX objects)"
)
else:
msg = "No new Metras data to import"

if errors:
msg += " | partial errors: " + "; ".join(errors)
self.helper.api.work.to_processed(work_id, msg)
self.helper.connector_logger.info(
"[CONNECTOR] Import cycle done", meta={"summary": msg}
)

# Advance cursors only for categories that did not hard-fail. Track failed
# categories explicitly (a set) rather than substring-matching the joined
# error text, which could be tripped by an exception message.
new_state = dict(state)
if "alerts" not in failed and new_alerts_max is not None:
new_state["alerts_last_occurrence"] = stix_timestamp(new_alerts_max)
if "binaries" not in failed and new_binaries_max:
new_state["binaries_last_seen"] = new_binaries_max
new_state["last_run"] = now_iso
self.helper.set_state(new_state)
Loading
Loading