-
Notifications
You must be signed in to change notification settings - Fork 622
feat(metras): add Metras external-import connector for EDR telemetry (#7042) #7039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Khidr6G
wants to merge
7
commits into
OpenCTI-Platform:master
Choose a base branch
from
Khidr6G:feature/metras-external-import
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d147bf3
feat(metras): add Metras external-import connector for EDR telemetry …
Khidr6G e35fd2c
fix(metras): stable incident id, fail-loud client, add config schema …
Khidr6G 5d489dd
fix(metras): set manifest source_code required by CI (#7042)
Khidr6G 69c09f8
fix(metras): address review feedback on external-import connector (#7…
Khidr6G a0818fe
fix(metras): add required manifest fields for updated schema (#7042)
Khidr6G 157b577
feat(metras): apply maintainer review — rename, scheduler, sdk models…
Khidr6G b98b229
fix(metras): pin pycti to 7.260728.0 to match connectors-sdk master (…
Khidr6G File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
22
external-import/metras-feed/__metadata__/connector_manifest.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)}, | ||
| ) | ||
|
|
||
| # ------------------------------------------------------------------ # | ||
| 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) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please use the official scheduler from
pyctiinstead of customwhileloop.