From d35725db36785b34e9254ba75ff7a2ea9b77098f Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Mon, 15 Jun 2026 11:41:05 +0200 Subject: [PATCH 1/4] feat(logrhythm-incidents): add external-import connector for LogRhythm cases Add a new EXTERNAL_IMPORT connector that periodically pulls LogRhythm cases via the Case API and imports them into OpenCTI as STIX Incidents. This is the import side of a bidirectional LogRhythm integration. Refs #6728 --- .../logrhythm-incidents/.dockerignore | 10 ++ .../logrhythm-incidents/Dockerfile | 19 +++ external-import/logrhythm-incidents/README.md | 100 ++++++++++++++ .../__metadata__/CONNECTOR_CONFIG_DOC.md | 20 +++ .../__metadata__/connector_config_schema.json | 98 ++++++++++++++ .../__metadata__/connector_manifest.json | 21 +++ .../logrhythm-incidents/docker-compose.yml | 21 +++ .../logrhythm-incidents/entrypoint.sh | 7 + .../logrhythm-incidents/src/config.yml.sample | 18 +++ .../src/connector/__init__.py | 7 + .../src/connector/connector.py | 70 ++++++++++ .../src/connector/converter_to_stix.py | 123 ++++++++++++++++++ .../src/connector/settings.py | 70 ++++++++++ .../src/logrhythm_client/__init__.py | 3 + .../src/logrhythm_client/api_client.py | 93 +++++++++++++ .../logrhythm-incidents/src/main.py | 23 ++++ .../logrhythm-incidents/src/requirements.txt | 5 + .../logrhythm-incidents/tests/__init__.py | 0 .../logrhythm-incidents/tests/conftest.py | 4 + .../tests/test-requirements.txt | 3 + .../logrhythm-incidents/tests/test_client.py | 66 ++++++++++ .../tests/test_connector.py | 62 +++++++++ .../tests/test_converter.py | 58 +++++++++ .../logrhythm-incidents/tests/test_main.py | 66 ++++++++++ .../tests/tests_connector/__init__.py | 0 .../tests/tests_connector/test_settings.py | 66 ++++++++++ 26 files changed, 1033 insertions(+) create mode 100644 external-import/logrhythm-incidents/.dockerignore create mode 100644 external-import/logrhythm-incidents/Dockerfile create mode 100644 external-import/logrhythm-incidents/README.md create mode 100644 external-import/logrhythm-incidents/__metadata__/CONNECTOR_CONFIG_DOC.md create mode 100644 external-import/logrhythm-incidents/__metadata__/connector_config_schema.json create mode 100644 external-import/logrhythm-incidents/__metadata__/connector_manifest.json create mode 100644 external-import/logrhythm-incidents/docker-compose.yml create mode 100644 external-import/logrhythm-incidents/entrypoint.sh create mode 100644 external-import/logrhythm-incidents/src/config.yml.sample create mode 100644 external-import/logrhythm-incidents/src/connector/__init__.py create mode 100644 external-import/logrhythm-incidents/src/connector/connector.py create mode 100644 external-import/logrhythm-incidents/src/connector/converter_to_stix.py create mode 100644 external-import/logrhythm-incidents/src/connector/settings.py create mode 100644 external-import/logrhythm-incidents/src/logrhythm_client/__init__.py create mode 100644 external-import/logrhythm-incidents/src/logrhythm_client/api_client.py create mode 100644 external-import/logrhythm-incidents/src/main.py create mode 100644 external-import/logrhythm-incidents/src/requirements.txt create mode 100644 external-import/logrhythm-incidents/tests/__init__.py create mode 100644 external-import/logrhythm-incidents/tests/conftest.py create mode 100644 external-import/logrhythm-incidents/tests/test-requirements.txt create mode 100644 external-import/logrhythm-incidents/tests/test_client.py create mode 100644 external-import/logrhythm-incidents/tests/test_connector.py create mode 100644 external-import/logrhythm-incidents/tests/test_converter.py create mode 100644 external-import/logrhythm-incidents/tests/test_main.py create mode 100644 external-import/logrhythm-incidents/tests/tests_connector/__init__.py create mode 100644 external-import/logrhythm-incidents/tests/tests_connector/test_settings.py diff --git a/external-import/logrhythm-incidents/.dockerignore b/external-import/logrhythm-incidents/.dockerignore new file mode 100644 index 00000000000..4a6ab3c7051 --- /dev/null +++ b/external-import/logrhythm-incidents/.dockerignore @@ -0,0 +1,10 @@ +__metadata__ +**/__pycache__ +**/__docs__ +**/.venv +**/venv +**/logs +**/config.yml +**/*.egg-info +**/*.gql +tests diff --git a/external-import/logrhythm-incidents/Dockerfile b/external-import/logrhythm-incidents/Dockerfile new file mode 100644 index 00000000000..de9efbb038a --- /dev/null +++ b/external-import/logrhythm-incidents/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-alpine +ENV CONNECTOR_TYPE=EXTERNAL_IMPORT + +# Copy the connector +COPY src /opt/opencti-connector-logrhythm-incidents + +# Install Python modules +# hadolint ignore=DL3003 +RUN apk update && apk upgrade && \ + apk --no-cache add git build-base libmagic libffi-dev libxml2-dev libxslt-dev + +RUN cd /opt/opencti-connector-logrhythm-incidents && \ + pip3 install --no-cache-dir -r requirements.txt && \ + apk del git build-base + +# Expose and entrypoint +COPY entrypoint.sh / +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/external-import/logrhythm-incidents/README.md b/external-import/logrhythm-incidents/README.md new file mode 100644 index 00000000000..eca15807e16 --- /dev/null +++ b/external-import/logrhythm-incidents/README.md @@ -0,0 +1,100 @@ +# OpenCTI LogRhythm Incidents Connector + +The LogRhythm Incidents connector is an **external-import** connector that pulls +cases from LogRhythm SIEM into OpenCTI as STIX Incidents. It is the import side of +a bidirectional integration: pair it with the existing `stream/logrhythm` +connector (which feeds LogRhythm lists from OpenCTI) to send IOCs out and bring +cases in. + +Table of Contents + +- [OpenCTI LogRhythm Incidents Connector](#opencti-logrhythm-incidents-connector) + - [Introduction](#introduction) + - [Requirements](#requirements) + - [Configuration variables](#configuration-variables) + - [OpenCTI environment variables](#opencti-environment-variables) + - [Base connector environment variables](#base-connector-environment-variables) + - [Connector extra parameters environment variables](#connector-extra-parameters-environment-variables) + - [Deployment](#deployment) + - [Docker Deployment](#docker-deployment) + - [Manual Deployment](#manual-deployment) + - [Behavior](#behavior) + +## Introduction + +LogRhythm is a SIEM platform. This connector periodically queries the LogRhythm +Case API for cases and imports them into OpenCTI as STIX 2.1 Incidents, attributed +to a LogRhythm author identity and marked with a configurable TLP. The LogRhythm +case priority (1-5) is mapped to the OpenCTI incident severity. + +## Requirements + +- OpenCTI Platform >= 7.260609.0 +- A reachable LogRhythm API gateway (Case API enabled) +- A LogRhythm API token (Bearer) + +## Configuration variables + +Configuration parameters can be provided in either `config.yml` (see +`config.yml.sample`), `docker-compose.yml` (environment variables) or directly as +environment variables. + +### OpenCTI environment variables + +| Parameter | config.yml | Docker environment variable | Mandatory | Description | +| ------------- | ---------- | --------------------------- | --------- | ---------------------------------------------------- | +| OpenCTI URL | `url` | `OPENCTI_URL` | Yes | The URL of the OpenCTI platform. | +| OpenCTI Token | `token` | `OPENCTI_TOKEN` | Yes | The default admin token set in the OpenCTI platform. | + +### Base connector environment variables + +| Parameter | config.yml | Docker environment variable | Default | Mandatory | Description | +| --------------- | ----------------- | ---------------------------- | --------------------- | --------- | --------------------------------------------------- | +| Connector ID | `id` | `CONNECTOR_ID` | / | Yes | A unique `UUIDv4` identifier for this connector. | +| Connector Name | `name` | `CONNECTOR_NAME` | `LogRhythm Incidents` | No | Name of the connector. | +| Connector Scope | `scope` | `CONNECTOR_SCOPE` | `logrhythm` | No | The scope of the connector. | +| Log Level | `log_level` | `CONNECTOR_LOG_LEVEL` | `error` | No | Logs verbosity (`debug`, `info`, `warn`, `error`). | +| Duration Period | `duration_period` | `CONNECTOR_DURATION_PERIOD` | `PT15M` | No | ISO-8601 period between two runs. | + +### Connector extra parameters environment variables + +| Parameter | config.yml | Docker environment variable | Default | Mandatory | Description | +| ------------ | -------------- | ------------------------------------ | ------- | --------- | ------------------------------------------------- | +| API base URL | `api_base_url` | `LOGRHYTHM_INCIDENTS_API_BASE_URL` | / | Yes | Base URL of the LogRhythm API gateway. | +| API token | `api_token` | `LOGRHYTHM_INCIDENTS_API_TOKEN` | / | Yes | LogRhythm API token (Bearer). | +| Max cases | `max_cases` | `LOGRHYTHM_INCIDENTS_MAX_CASES` | `200` | No | Maximum number of cases to fetch per run. | +| TLP level | `tlp_level` | `LOGRHYTHM_INCIDENTS_TLP_LEVEL` | `amber` | No | TLP marking applied to imported incidents. | +| SSL verify | `ssl_verify` | `LOGRHYTHM_INCIDENTS_SSL_VERIFY` | `true` | No | Whether to verify the SSL certificate. | + +## Deployment + +### Docker Deployment + +Build a Docker image using the provided `Dockerfile`: + +```shell +docker build . -t opencti/connector-logrhythm-incidents:rolling +``` + +Make sure to replace the environment variables in `docker-compose.yml` with the +appropriate configurations, then start the connector: + +```shell +docker compose up -d +``` + +### Manual Deployment + +Create a `config.yml` file from `config.yml.sample` and fill in the values, then: + +```shell +cd src +pip install -r requirements.txt +python main.py +``` + +## Behavior + +On each run the connector fetches cases from the LogRhythm Case API (capped at +`max_cases`), converts each case to a STIX Incident and sends the bundle to +OpenCTI. OpenCTI deduplicates incidents by their deterministic id across runs. diff --git a/external-import/logrhythm-incidents/__metadata__/CONNECTOR_CONFIG_DOC.md b/external-import/logrhythm-incidents/__metadata__/CONNECTOR_CONFIG_DOC.md new file mode 100644 index 00000000000..2ed47cd4edd --- /dev/null +++ b/external-import/logrhythm-incidents/__metadata__/CONNECTOR_CONFIG_DOC.md @@ -0,0 +1,20 @@ +# Connector Configurations + +Below is an exhaustive enumeration of all configurable parameters available, each accompanied by detailed explanations of their purposes, default behaviors, and usage guidelines to help you understand and utilize them effectively. + +### Type: `object` + +| Property | Type | Required | Possible values | Default | Description | +| -------- | ---- | -------- | --------------- | ------- | ----------- | +| OPENCTI_URL | `string` | ✅ | Format: [`uri`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | | The base URL of the OpenCTI instance. | +| OPENCTI_TOKEN | `string` | ✅ | string | | The API token to connect to OpenCTI. | +| CONNECTOR_SCOPE | `array` | ✅ | string | | The scope of the connector, e.g. 'flashpoint'. | +| LOGRHYTHM_INCIDENTS_API_BASE_URL | `string` | ✅ | Format: [`uri`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | | Base URL of the LogRhythm API gateway (e.g. https://logrhythm.example.com:8501). | +| LOGRHYTHM_INCIDENTS_API_TOKEN | `string` | ✅ | Format: [`password`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | | LogRhythm API token (Bearer) used for authentication. | +| CONNECTOR_NAME | `string` | | string | `"LogRhythm Incidents"` | The name of the connector. | +| CONNECTOR_LOG_LEVEL | `string` | | `debug` `info` `warn` `warning` `error` | `"error"` | The minimum level of logs to display. | +| CONNECTOR_TYPE | `const` | | `EXTERNAL_IMPORT` | `"EXTERNAL_IMPORT"` | | +| CONNECTOR_DURATION_PERIOD | `string` | | Format: [`duration`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | `"PT15M"` | The period of time to await between two runs of the connector. | +| LOGRHYTHM_INCIDENTS_MAX_CASES | `integer` | | `1 <= x ` | `200` | Maximum number of LogRhythm cases to fetch per run. | +| LOGRHYTHM_INCIDENTS_TLP_LEVEL | `string` | | `clear` `white` `green` `amber` `amber+strict` `red` | `"amber"` | TLP marking applied to the imported incidents. | +| LOGRHYTHM_INCIDENTS_SSL_VERIFY | `boolean` | | boolean | `true` | Whether to verify the SSL certificate of the LogRhythm API gateway. | diff --git a/external-import/logrhythm-incidents/__metadata__/connector_config_schema.json b/external-import/logrhythm-incidents/__metadata__/connector_config_schema.json new file mode 100644 index 00000000000..e8264e2b029 --- /dev/null +++ b/external-import/logrhythm-incidents/__metadata__/connector_config_schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.filigran.io/connectors/logrhythm-incidents_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": "LogRhythm Incidents", + "description": "The name of the connector.", + "type": "string" + }, + "CONNECTOR_SCOPE": { + "description": "The scope of the connector, e.g. 'flashpoint'.", + "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": "EXTERNAL_IMPORT", + "default": "EXTERNAL_IMPORT", + "type": "string" + }, + "CONNECTOR_DURATION_PERIOD": { + "default": "PT15M", + "description": "The period of time to await between two runs of the connector.", + "format": "duration", + "type": "string" + }, + "LOGRHYTHM_INCIDENTS_API_BASE_URL": { + "description": "Base URL of the LogRhythm API gateway (e.g. https://logrhythm.example.com:8501).", + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + "LOGRHYTHM_INCIDENTS_API_TOKEN": { + "description": "LogRhythm API token (Bearer) used for authentication.", + "format": "password", + "type": "string", + "writeOnly": true + }, + "LOGRHYTHM_INCIDENTS_MAX_CASES": { + "default": 200, + "description": "Maximum number of LogRhythm cases to fetch per run.", + "minimum": 1, + "type": "integer" + }, + "LOGRHYTHM_INCIDENTS_TLP_LEVEL": { + "default": "amber", + "description": "TLP marking applied to the imported incidents.", + "enum": [ + "clear", + "white", + "green", + "amber", + "amber+strict", + "red" + ], + "type": "string" + }, + "LOGRHYTHM_INCIDENTS_SSL_VERIFY": { + "default": true, + "description": "Whether to verify the SSL certificate of the LogRhythm API gateway.", + "type": "boolean" + } + }, + "required": [ + "OPENCTI_URL", + "OPENCTI_TOKEN", + "CONNECTOR_SCOPE", + "LOGRHYTHM_INCIDENTS_API_BASE_URL", + "LOGRHYTHM_INCIDENTS_API_TOKEN" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/external-import/logrhythm-incidents/__metadata__/connector_manifest.json b/external-import/logrhythm-incidents/__metadata__/connector_manifest.json new file mode 100644 index 00000000000..7adf87e0796 --- /dev/null +++ b/external-import/logrhythm-incidents/__metadata__/connector_manifest.json @@ -0,0 +1,21 @@ +{ + "title": "LogRhythm Incidents", + "slug": "logrhythm-incidents", + "description": "The OpenCTI LogRhythm Incidents connector imports cases from LogRhythm SIEM into OpenCTI as STIX Incidents. It periodically pulls cases through the LogRhythm Case API and converts them to STIX 2.1 Incidents (with priority-based severity, timestamps and an external reference). Paired with the existing LogRhythm stream connector (which feeds LogRhythm lists from OpenCTI), it provides the import side of a bidirectional integration.", + "short_description": "Import LogRhythm cases into OpenCTI as STIX Incidents (bidirectional import side).", + "logo": null, + "use_cases": [ + "SIEM & Analytics" + ], + "verified": false, + "last_verified_date": null, + "playbook_supported": false, + "max_confidence_level": 50, + "support_version": ">=7.260609.0", + "subscription_link": "https://logrhythm.com", + "source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/external-import/logrhythm-incidents", + "manager_supported": true, + "container_version": "rolling", + "container_image": "opencti/connector-logrhythm-incidents", + "container_type": "EXTERNAL_IMPORT" +} diff --git a/external-import/logrhythm-incidents/docker-compose.yml b/external-import/logrhythm-incidents/docker-compose.yml new file mode 100644 index 00000000000..fc0be617023 --- /dev/null +++ b/external-import/logrhythm-incidents/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3' +services: + connector-logrhythm-incidents: + image: opencti/connector-logrhythm-incidents:rolling + environment: + # Generic parameters (connection with OpenCTI) + - OPENCTI_URL=http://localhost + - OPENCTI_TOKEN=CHANGEME + # Common parameters for connectors of type EXTERNAL_IMPORT + - CONNECTOR_ID=CHANGEME + - CONNECTOR_NAME=LogRhythm Incidents # optional (default: 'LogRhythm Incidents') + - CONNECTOR_SCOPE=logrhythm # optional + - CONNECTOR_LOG_LEVEL=error # optional (default: 'error') + - CONNECTOR_DURATION_PERIOD=PT15M # optional (default: 'PT15M') + # LogRhythm parameters + - LOGRHYTHM_INCIDENTS_API_BASE_URL=CHANGEME # e.g. https://logrhythm.example.com:8501 + - LOGRHYTHM_INCIDENTS_API_TOKEN=CHANGEME + - LOGRHYTHM_INCIDENTS_MAX_CASES=200 # optional (default: 200) + - LOGRHYTHM_INCIDENTS_TLP_LEVEL=amber # optional (default: 'amber') + - LOGRHYTHM_INCIDENTS_SSL_VERIFY=true # optional (default: true) + restart: always diff --git a/external-import/logrhythm-incidents/entrypoint.sh b/external-import/logrhythm-incidents/entrypoint.sh new file mode 100644 index 00000000000..c188f3d8ba5 --- /dev/null +++ b/external-import/logrhythm-incidents/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# Go to the right directory +cd /opt/opencti-connector-logrhythm-incidents + +# Launch the worker +python3 main.py diff --git a/external-import/logrhythm-incidents/src/config.yml.sample b/external-import/logrhythm-incidents/src/config.yml.sample new file mode 100644 index 00000000000..8d6708ce6b1 --- /dev/null +++ b/external-import/logrhythm-incidents/src/config.yml.sample @@ -0,0 +1,18 @@ +opencti: + url: 'http://localhost' + token: 'ChangeMe' + +connector: + id: 'ChangeMe' + type: 'EXTERNAL_IMPORT' + name: 'LogRhythm Incidents' # optional (default: 'LogRhythm Incidents') + scope: 'logrhythm' # optional + log_level: 'error' # optional (default: 'error') + duration_period: 'PT15M' # optional (default: 'PT15M') + +logrhythm_incidents: + api_base_url: 'ChangeMe' # Base URL of the LogRhythm API gateway, e.g. https://logrhythm.example.com:8501 + api_token: 'ChangeMe' # LogRhythm API token (Bearer) + max_cases: 200 # optional (default: 200) + tlp_level: 'amber' # optional, one of clear/green/amber/amber+strict/red (default: 'amber') + ssl_verify: true # optional (default: true) diff --git a/external-import/logrhythm-incidents/src/connector/__init__.py b/external-import/logrhythm-incidents/src/connector/__init__.py new file mode 100644 index 00000000000..c88dffd3eb0 --- /dev/null +++ b/external-import/logrhythm-incidents/src/connector/__init__.py @@ -0,0 +1,7 @@ +from connector.connector import LogRhythmIncidentsConnector +from connector.settings import ConnectorSettings + +__all__ = [ + "LogRhythmIncidentsConnector", + "ConnectorSettings", +] diff --git a/external-import/logrhythm-incidents/src/connector/connector.py b/external-import/logrhythm-incidents/src/connector/connector.py new file mode 100644 index 00000000000..a4b873d50b0 --- /dev/null +++ b/external-import/logrhythm-incidents/src/connector/connector.py @@ -0,0 +1,70 @@ +import sys +from datetime import datetime, timezone + +from connector.converter_to_stix import ConverterToStix +from connector.settings import ConnectorSettings +from logrhythm_client import LogRhythmClient +from pycti import OpenCTIConnectorHelper + + +class LogRhythmIncidentsConnector: + """ + External-import connector that pulls LogRhythm cases into OpenCTI as STIX + Incidents. + """ + + def __init__(self, config: ConnectorSettings, helper: OpenCTIConnectorHelper): + self.config = config + self.helper = helper + self.client = LogRhythmClient(config, helper) + self.converter = ConverterToStix( + helper, tlp_level=self.config.logrhythm_incidents.tlp_level + ) + + def _collect_intelligence(self) -> list: + stix_objects: list = [] + for case in self.client.get_cases(): + incident = self.converter.create_incident(case) + if incident is not None: + stix_objects.append(incident) + + if stix_objects: + stix_objects.append(self.converter.author) + stix_objects.append(self.converter.tlp_marking) + return stix_objects + + def process_message(self) -> None: + self.helper.connector_logger.info( + "[CONNECTOR] Starting LogRhythm Incidents connector..." + ) + try: + now = datetime.now(timezone.utc) + current_state = self.helper.get_state() or {} + + work_id = self.helper.api.work.initiate_work( + self.helper.connect_id, "LogRhythm Incidents run" + ) + + stix_objects = self._collect_intelligence() + if stix_objects: + bundle = self.helper.stix2_create_bundle(stix_objects) + self.helper.send_stix2_bundle( + bundle, work_id=work_id, cleanup_inconsistent_bundle=True + ) + + current_state["last_run"] = now.isoformat() + self.helper.set_state(current_state) + self.helper.api.work.to_processed( + work_id, "LogRhythm Incidents connector successfully run" + ) + except (KeyboardInterrupt, SystemExit): + self.helper.connector_logger.info("[CONNECTOR] Connector stopped...") + sys.exit(0) + except Exception as err: + self.helper.connector_logger.error(str(err)) + + def run(self) -> None: + self.helper.schedule_process( + message_callback=self.process_message, + duration_period=self.config.connector.duration_period.total_seconds(), + ) diff --git a/external-import/logrhythm-incidents/src/connector/converter_to_stix.py b/external-import/logrhythm-incidents/src/connector/converter_to_stix.py new file mode 100644 index 00000000000..420244b8bff --- /dev/null +++ b/external-import/logrhythm-incidents/src/connector/converter_to_stix.py @@ -0,0 +1,123 @@ +"""Converter from LogRhythm cases to STIX 2.1 objects.""" + +from datetime import datetime, timezone +from typing import Optional + +import stix2 +from pycti import Identity, Incident, MarkingDefinition + +_TLP_MAPPING = { + "clear": stix2.TLP_WHITE, + "white": stix2.TLP_WHITE, + "green": stix2.TLP_GREEN, + "amber": stix2.TLP_AMBER, + "red": stix2.TLP_RED, +} + +# LogRhythm case priority is a 1-5 scale (5 = highest). +_PRIORITY_MAPPING = {5: "critical", 4: "high", 3: "medium", 2: "low", 1: "low"} + + +def _amber_strict() -> stix2.MarkingDefinition: + return stix2.MarkingDefinition( + id=MarkingDefinition.generate_id("TLP", "TLP:AMBER+STRICT"), + definition_type="statement", + definition={"statement": "custom"}, + custom_properties={ + "x_opencti_definition_type": "TLP", + "x_opencti_definition": "TLP:AMBER+STRICT", + }, + ) + + +class ConverterToStix: + """Convert LogRhythm case dictionaries into STIX 2.1 objects.""" + + def __init__(self, helper, tlp_level: str): + self.helper = helper + self.author = self._create_author() + if tlp_level == "amber+strict": + self.tlp_marking = _amber_strict() + else: + self.tlp_marking = _TLP_MAPPING.get(tlp_level, stix2.TLP_AMBER) + + @staticmethod + def _create_author() -> stix2.Identity: + return stix2.Identity( + id=Identity.generate_id(name="LogRhythm", identity_class="organization"), + name="LogRhythm", + identity_class="organization", + description="Cases imported from LogRhythm.", + ) + + @staticmethod + def _to_iso(value) -> str: + """ + Best-effort conversion of a LogRhythm timestamp to a STIX-compatible UTC + timestamp string (millisecond precision, ``Z`` suffix). + """ + if value is None or value == "": + dt = datetime.now(timezone.utc) + elif isinstance(value, (int, float)) or ( + isinstance(value, str) and value.isdigit() + ): + epoch = float(value) + if epoch > 1e12: # milliseconds + epoch /= 1000.0 + dt = datetime.fromtimestamp(epoch, tz=timezone.utc) + else: + try: + dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + dt = ( + dt.replace(tzinfo=timezone.utc) + if dt.tzinfo is None + else dt.astimezone(timezone.utc) + ) + except ValueError: + dt = datetime.now(timezone.utc) + return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + @staticmethod + def _map_severity(value) -> str: + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("low", "medium", "high", "critical"): + return lowered + if lowered.isdigit(): + value = int(lowered) + if isinstance(value, (int, float)): + return _PRIORITY_MAPPING.get(int(value), "low") + return "low" + + def create_incident(self, case: dict) -> Optional[stix2.Incident]: + """Create a STIX Incident from a LogRhythm case dictionary.""" + number = str(case.get("number") or case.get("id") or "").strip() + name = case.get("name") or ( + f"LogRhythm case {number}" if number else "LogRhythm case" + ) + created = self._to_iso(case.get("dateCreated") or case.get("createdDate")) + modified = self._to_iso( + case.get("dateUpdated") or case.get("modifiedDate") or created + ) + severity = self._map_severity(case.get("priority")) + description = case.get("summary") or "Case imported from LogRhythm." + + external_references = None + if number: + external_references = [{"source_name": "LogRhythm", "external_id": number}] + + return stix2.Incident( + id=Incident.generate_id(name, created), + name=name, + description=description, + created=created, + modified=modified, + created_by_ref=self.author["id"], + object_marking_refs=[self.tlp_marking], + external_references=external_references, + custom_properties={ + "source": "LogRhythm", + "severity": severity, + "incident_type": "alert", + }, + ) diff --git a/external-import/logrhythm-incidents/src/connector/settings.py b/external-import/logrhythm-incidents/src/connector/settings.py new file mode 100644 index 00000000000..2efe853dd9e --- /dev/null +++ b/external-import/logrhythm-incidents/src/connector/settings.py @@ -0,0 +1,70 @@ +from datetime import timedelta +from typing import Literal + +from connectors_sdk import ( + BaseConfigModel, + BaseConnectorSettings, + BaseExternalImportConnectorConfig, +) +from pydantic import AliasChoices, Field, HttpUrl, SecretStr + + +class ExternalImportConnectorConfig(BaseExternalImportConnectorConfig): + """ + Override the `BaseExternalImportConnectorConfig` to add parameters and/or defaults + to the configuration for connectors of type `EXTERNAL_IMPORT`. + """ + + name: str = Field( + description="The name of the connector.", + default="LogRhythm Incidents", + ) + duration_period: timedelta = Field( + description="The period of time to await between two runs of the connector.", + default=timedelta(minutes=15), + ) + + +class LogRhythmIncidentsConfig(BaseConfigModel): + """ + Define parameters and/or defaults for the configuration specific to the LogRhythm Incidents connector. + """ + + api_base_url: HttpUrl = Field( + description="Base URL of the LogRhythm API gateway (e.g. https://logrhythm.example.com:8501).", + validation_alias=AliasChoices("api_base_url", "url"), + serialization_alias="api_base_url", + ) + api_token: SecretStr = Field( + description="LogRhythm API token (Bearer) used for authentication.", + validation_alias=AliasChoices("api_token", "token"), + serialization_alias="api_token", + ) + max_cases: int = Field( + description="Maximum number of LogRhythm cases to fetch per run.", + default=200, + ge=1, + ) + tlp_level: Literal["clear", "white", "green", "amber", "amber+strict", "red"] = ( + Field( + description="TLP marking applied to the imported incidents.", + default="amber", + ) + ) + ssl_verify: bool = Field( + description="Whether to verify the SSL certificate of the LogRhythm API gateway.", + default=True, + ) + + +class ConnectorSettings(BaseConnectorSettings): + """ + Override `BaseConnectorSettings` to include `ExternalImportConnectorConfig` and `LogRhythmIncidentsConfig`. + """ + + connector: ExternalImportConnectorConfig = Field( + default_factory=ExternalImportConnectorConfig + ) + logrhythm_incidents: LogRhythmIncidentsConfig = Field( + default_factory=LogRhythmIncidentsConfig + ) diff --git a/external-import/logrhythm-incidents/src/logrhythm_client/__init__.py b/external-import/logrhythm-incidents/src/logrhythm_client/__init__.py new file mode 100644 index 00000000000..611118c7f03 --- /dev/null +++ b/external-import/logrhythm-incidents/src/logrhythm_client/__init__.py @@ -0,0 +1,3 @@ +from logrhythm_client.api_client import LogRhythmClient + +__all__ = ["LogRhythmClient"] diff --git a/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py b/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py new file mode 100644 index 00000000000..5b50831688a --- /dev/null +++ b/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py @@ -0,0 +1,93 @@ +"""HTTP client for the LogRhythm Case API.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Optional + +import requests +from pycti import OpenCTIConnectorHelper + +if TYPE_CHECKING: + from connector.settings import ConnectorSettings + +CASES_PATH = "/lr-case-api/cases" + + +class LogRhythmClient: + """Thin client around the LogRhythm Case API.""" + + REQUEST_ATTEMPTS = 3 + BACKOFF_FACTOR = 5 + TIMEOUT = 60 + + def __init__( + self, config: ConnectorSettings, helper: OpenCTIConnectorHelper + ) -> None: + """ + Initialize the LogRhythm client. + + :param config: The connector settings. + :param helper: The OpenCTI connector helper (used for logging). + """ + self.helper = helper + self.config = config.logrhythm_incidents + + self._base_url = str(self.config.api_base_url).rstrip("/") + + self.session = requests.Session() + self.session.verify = self.config.ssl_verify + self.session.headers.update( + { + "Authorization": f"Bearer {self.config.api_token.get_secret_value()}", + "Accept": "application/json", + } + ) + + def get_cases(self) -> list: + """Fetch LogRhythm cases, capped at the configured maximum.""" + response = self._request( + "get", CASES_PATH, params={"count": self.config.max_cases} + ) + if response is None: + return [] + try: + return self._extract_cases(response.json()) + except ValueError: + self.helper.connector_logger.error( + "[API] Unexpected LogRhythm cases response" + ) + return [] + + @staticmethod + def _extract_cases(payload) -> list: + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in ("cases", "items", "data", "results"): + value = payload.get(key) + if isinstance(value, list): + return value + return [] + + def _request(self, method: str, path: str, **kwargs) -> Optional[requests.Response]: + """Perform an HTTP request with retry/backoff on rate limiting and transient errors.""" + url = f"{self._base_url}{path}" + for attempt in range(self.REQUEST_ATTEMPTS): + try: + response = self.session.request( + method, url, timeout=self.TIMEOUT, **kwargs + ) + if response.status_code == 429 and attempt < self.REQUEST_ATTEMPTS - 1: + time.sleep(self.BACKOFF_FACTOR * (2**attempt)) + continue + response.raise_for_status() + return response + except requests.RequestException as err: + self.helper.connector_logger.warning( + "[API] LogRhythm request failed", + {"url": url, "error": str(err)}, + ) + if attempt < self.REQUEST_ATTEMPTS - 1: + time.sleep(self.BACKOFF_FACTOR * (2**attempt)) + return None diff --git a/external-import/logrhythm-incidents/src/main.py b/external-import/logrhythm-incidents/src/main.py new file mode 100644 index 00000000000..3e6a796fcc2 --- /dev/null +++ b/external-import/logrhythm-incidents/src/main.py @@ -0,0 +1,23 @@ +import traceback + +from connector import ConnectorSettings, LogRhythmIncidentsConnector +from pycti import OpenCTIConnectorHelper + +if __name__ == "__main__": + """ + Entry point of the script. + + - traceback.print_exc(): prints the traceback of the exception to stderr, + which is very useful for debugging purposes. + - exit(1): terminates the program signalling that it did not complete + successfully. + """ + try: + settings = ConnectorSettings() + helper = OpenCTIConnectorHelper(config=settings.to_helper_config()) + + connector = LogRhythmIncidentsConnector(config=settings, helper=helper) + connector.run() + except Exception: + traceback.print_exc() + exit(1) diff --git a/external-import/logrhythm-incidents/src/requirements.txt b/external-import/logrhythm-incidents/src/requirements.txt new file mode 100644 index 00000000000..c80e5a41c7d --- /dev/null +++ b/external-import/logrhythm-incidents/src/requirements.txt @@ -0,0 +1,5 @@ +pycti==7.260609.0 +pydantic~=2.11.3 +requests~=2.33.0 +stix2~=3.0.1 +connectors-sdk @ git+https://github.com/OpenCTI-Platform/connectors.git@master#subdirectory=connectors-sdk diff --git a/external-import/logrhythm-incidents/tests/__init__.py b/external-import/logrhythm-incidents/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/external-import/logrhythm-incidents/tests/conftest.py b/external-import/logrhythm-incidents/tests/conftest.py new file mode 100644 index 00000000000..d7821cacd41 --- /dev/null +++ b/external-import/logrhythm-incidents/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/external-import/logrhythm-incidents/tests/test-requirements.txt b/external-import/logrhythm-incidents/tests/test-requirements.txt new file mode 100644 index 00000000000..38f23c0ab30 --- /dev/null +++ b/external-import/logrhythm-incidents/tests/test-requirements.txt @@ -0,0 +1,3 @@ +# Main dependencies needs to be installed +-r ../src/requirements.txt +pytest==9.0.3 diff --git a/external-import/logrhythm-incidents/tests/test_client.py b/external-import/logrhythm-incidents/tests/test_client.py new file mode 100644 index 00000000000..cd38f34f489 --- /dev/null +++ b/external-import/logrhythm-incidents/tests/test_client.py @@ -0,0 +1,66 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import requests +from logrhythm_client import LogRhythmClient + + +def _make_client() -> LogRhythmClient: + logrhythm = SimpleNamespace( + api_base_url="https://logrhythm.example.com:8501", + api_token=SimpleNamespace(get_secret_value=lambda: "tok"), + max_cases=200, + ssl_verify=True, + ) + client = LogRhythmClient( + SimpleNamespace(logrhythm_incidents=logrhythm), MagicMock() + ) + client.session = MagicMock() + return client + + +def _response(payload) -> MagicMock: + response = MagicMock() + response.status_code = 200 + response.json.return_value = payload + response.raise_for_status.return_value = None + return response + + +def test_extract_cases_variants(): + assert LogRhythmClient._extract_cases([{"id": 1}]) == [{"id": 1}] + assert LogRhythmClient._extract_cases({"cases": [{"id": 2}]}) == [{"id": 2}] + assert LogRhythmClient._extract_cases({"unexpected": 1}) == [] + + +def test_get_cases_happy_path(): + client = _make_client() + client.session.request.return_value = _response([{"number": "1"}]) + + cases = client.get_cases() + assert cases == [{"number": "1"}] + call = client.session.request.call_args + assert call.args[0] == "get" + assert call.args[1].endswith("/lr-case-api/cases") + assert call.kwargs["params"]["count"] == 200 + + +def test_get_cases_returns_empty_on_error(): + client = _make_client() + client.session.request.side_effect = requests.RequestException("boom") + with patch("logrhythm_client.api_client.time.sleep"): + assert client.get_cases() == [] + + +def test_request_retries_on_rate_limit(): + client = _make_client() + rate_limited = MagicMock() + rate_limited.status_code = 429 + client.session.request.side_effect = [rate_limited, _response([])] + + with patch("logrhythm_client.api_client.time.sleep") as sleep: + result = client._request("get", "/lr-case-api/cases") + + assert result is not None + assert client.session.request.call_count == 2 + sleep.assert_called_once() diff --git a/external-import/logrhythm-incidents/tests/test_connector.py b/external-import/logrhythm-incidents/tests/test_connector.py new file mode 100644 index 00000000000..f51b32b7fb8 --- /dev/null +++ b/external-import/logrhythm-incidents/tests/test_connector.py @@ -0,0 +1,62 @@ +from unittest.mock import MagicMock, patch + +from connector.connector import LogRhythmIncidentsConnector + + +def _make_connector(): + helper = MagicMock() + with patch("connector.connector.LogRhythmClient") as client_cls: + connector = LogRhythmIncidentsConnector(config=MagicMock(), helper=helper) + connector.client = client_cls.return_value + return connector, helper, connector.client + + +def test_collect_intelligence_builds_objects(): + connector, _, client = _make_connector() + client.get_cases.return_value = [{"name": "Case A", "number": "1"}] + + objects = connector._collect_intelligence() + types = [o["type"] for o in objects] + + assert "incident" in types + assert "identity" in types # author appended + + +def test_collect_intelligence_empty(): + connector, _, client = _make_connector() + client.get_cases.return_value = [] + + assert connector._collect_intelligence() == [] + + +def test_process_message_sends_bundle(): + connector, helper, client = _make_connector() + helper.get_state.return_value = {} + helper.api.work.initiate_work.return_value = "work-1" + helper.stix2_create_bundle.return_value = "bundle" + client.get_cases.return_value = [{"name": "Case A", "number": "1"}] + + connector.process_message() + + helper.send_stix2_bundle.assert_called_once() + helper.set_state.assert_called_once() + helper.api.work.to_processed.assert_called_once() + + +def test_process_message_handles_errors(): + connector, helper, client = _make_connector() + helper.get_state.return_value = {} + helper.api.work.initiate_work.return_value = "work-1" + client.get_cases.side_effect = RuntimeError("boom") + + connector.process_message() # must not raise + + helper.connector_logger.error.assert_called() + + +def test_run_schedules_process(): + connector, helper, _ = _make_connector() + + connector.run() + + helper.schedule_process.assert_called_once() diff --git a/external-import/logrhythm-incidents/tests/test_converter.py b/external-import/logrhythm-incidents/tests/test_converter.py new file mode 100644 index 00000000000..40f44ab211c --- /dev/null +++ b/external-import/logrhythm-incidents/tests/test_converter.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock + +import pytest +from connector.converter_to_stix import ConverterToStix + + +def _converter(tlp_level: str = "amber") -> ConverterToStix: + return ConverterToStix(MagicMock(), tlp_level=tlp_level) + + +def test_author_is_identity(): + converter = _converter() + assert converter.author["type"] == "identity" + assert converter.author["name"] == "LogRhythm" + + +def test_amber_strict_marking(): + converter = _converter("amber+strict") + assert converter.tlp_marking["definition_type"] == "statement" + + +@pytest.mark.parametrize( + "value, expected", + [ + (5, "critical"), + (4, "high"), + (3, "medium"), + (2, "low"), + (1, "low"), + ("high", "high"), + ("5", "critical"), + (None, "low"), + ], +) +def test_map_severity(value, expected): + assert ConverterToStix._map_severity(value) == expected + + +def test_create_incident(): + converter = _converter() + incident = converter.create_incident( + { + "name": "Case A", + "number": "42", + "priority": 5, + "dateCreated": "2024-05-01T00:00:00Z", + "summary": "Case details", + } + ) + assert incident["type"] == "incident" + assert incident["name"] == "Case A" + assert incident["external_references"][0]["external_id"] == "42" + + +def test_create_incident_minimal(): + converter = _converter() + incident = converter.create_incident({}) + assert incident["name"] == "LogRhythm case" diff --git a/external-import/logrhythm-incidents/tests/test_main.py b/external-import/logrhythm-incidents/tests/test_main.py new file mode 100644 index 00000000000..60c6672f509 --- /dev/null +++ b/external-import/logrhythm-incidents/tests/test_main.py @@ -0,0 +1,66 @@ +from typing import Any +from unittest.mock import MagicMock + +import pytest +from connector import ConnectorSettings, LogRhythmIncidentsConnector +from pycti import OpenCTIConnectorHelper + + +@pytest.fixture +def mock_opencti_connector_helper(monkeypatch): + """Mock all heavy dependencies of OpenCTIConnectorHelper, typically API calls to OpenCTI.""" + + module_import_path = "pycti.connector.opencti_connector_helper" + monkeypatch.setattr(f"{module_import_path}.killProgramHook", MagicMock()) + monkeypatch.setattr(f"{module_import_path}.sched.scheduler", MagicMock()) + monkeypatch.setattr(f"{module_import_path}.ConnectorInfo", MagicMock()) + monkeypatch.setattr(f"{module_import_path}.OpenCTIApiClient", MagicMock()) + monkeypatch.setattr(f"{module_import_path}.OpenCTIConnector", MagicMock()) + monkeypatch.setattr(f"{module_import_path}.OpenCTIMetricHandler", MagicMock()) + monkeypatch.setattr(f"{module_import_path}.PingAlive", MagicMock()) + + +class StubConnectorSettings(ConnectorSettings): + """Subclass of `ConnectorSettings` returning a fake but valid config dict.""" + + @classmethod + def _load_config_dict(cls, _, handler) -> dict[str, Any]: + return handler( + { + "opencti": { + "url": "http://localhost:8080", + "token": "test-token", + }, + "connector": { + "id": "connector-id", + "name": "LogRhythm Incidents", + "scope": "logrhythm", + "log_level": "error", + "duration_period": "PT15M", + }, + "logrhythm_incidents": { + "api_base_url": "https://logrhythm.example.com:8501", + "api_token": "test-token", + "max_cases": 200, + "tlp_level": "amber", + "ssl_verify": True, + }, + } + ) + + +def test_connector_settings_is_instantiated(): + settings = StubConnectorSettings() + + assert isinstance(settings, ConnectorSettings) + assert isinstance(settings.to_helper_config(), dict) + + +def test_connector_is_instantiated(mock_opencti_connector_helper): + settings = StubConnectorSettings() + helper = OpenCTIConnectorHelper(config=settings.to_helper_config()) + + connector = LogRhythmIncidentsConnector(config=settings, helper=helper) + + assert connector.config == settings + assert connector.helper == helper diff --git a/external-import/logrhythm-incidents/tests/tests_connector/__init__.py b/external-import/logrhythm-incidents/tests/tests_connector/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/external-import/logrhythm-incidents/tests/tests_connector/test_settings.py b/external-import/logrhythm-incidents/tests/tests_connector/test_settings.py new file mode 100644 index 00000000000..def9e5c590a --- /dev/null +++ b/external-import/logrhythm-incidents/tests/tests_connector/test_settings.py @@ -0,0 +1,66 @@ +from typing import Any + +import pytest +from connector import ConnectorSettings +from connectors_sdk import BaseConfigModel, ConfigValidationError + + +def _valid_settings() -> dict[str, Any]: + return { + "opencti": {"url": "http://localhost:8080", "token": "test-token"}, + "connector": { + "id": "connector-id", + "name": "LogRhythm Incidents", + "scope": "logrhythm", + "log_level": "error", + "duration_period": "PT15M", + }, + "logrhythm_incidents": { + "api_base_url": "https://logrhythm.example.com:8501", + "api_token": "test-token", + }, + } + + +def test_settings_should_accept_valid_input(): + class FakeConnectorSettings(ConnectorSettings): + @classmethod + def _load_config_dict(cls, _, handler) -> dict[str, Any]: + return handler(_valid_settings()) + + settings = FakeConnectorSettings() + + assert isinstance(settings.logrhythm_incidents, BaseConfigModel) is True + assert settings.logrhythm_incidents.max_cases == 200 + assert settings.logrhythm_incidents.tlp_level == "amber" + + +@pytest.mark.parametrize( + "settings_dict", + [ + pytest.param({}, id="empty_settings_dict"), + pytest.param( + { + "opencti": {"url": "http://localhost:8080", "token": "test-token"}, + "connector": { + "id": "connector-id", + "scope": "logrhythm", + "duration_period": "PT15M", + }, + "logrhythm_incidents": { + "max_cases": 200, + }, + }, + id="missing_api_base_url", + ), + ], +) +def test_settings_should_raise_when_invalid_input(settings_dict): + class FakeConnectorSettings(ConnectorSettings): + @classmethod + def _load_config_dict(cls, _, handler) -> dict[str, Any]: + return handler(settings_dict) + + with pytest.raises(ConfigValidationError) as err: + FakeConnectorSettings() + assert str("Error validating configuration") in str(err) From 08ff1fcc50fb61f99980bb256439247cebd1afd0 Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Mon, 15 Jun 2026 12:01:52 +0200 Subject: [PATCH 2/4] fix(logrhythm-incidents): model LogRhythm cases as Case-Incidents LogRhythm cases are case-management artifacts, so they must map to OpenCTI Case-Incidents (CustomObjectCaseIncident) rather than Incidents, which are reserved for alarms/detections. Add severity-based priority. Refs #6728 --- external-import/logrhythm-incidents/README.md | 11 +++++--- .../__metadata__/connector_manifest.json | 4 +-- .../src/connector/connector.py | 6 ++--- .../src/connector/converter_to_stix.py | 26 ++++++++++++------- .../tests/test_connector.py | 2 +- .../tests/test_converter.py | 17 ++++++------ 6 files changed, 38 insertions(+), 28 deletions(-) diff --git a/external-import/logrhythm-incidents/README.md b/external-import/logrhythm-incidents/README.md index eca15807e16..996f236a18b 100644 --- a/external-import/logrhythm-incidents/README.md +++ b/external-import/logrhythm-incidents/README.md @@ -1,11 +1,14 @@ # OpenCTI LogRhythm Incidents Connector The LogRhythm Incidents connector is an **external-import** connector that pulls -cases from LogRhythm SIEM into OpenCTI as STIX Incidents. It is the import side of -a bidirectional integration: pair it with the existing `stream/logrhythm` +cases from LogRhythm SIEM into OpenCTI as STIX **Case-Incidents**. It is the import +side of a bidirectional integration: pair it with the existing `stream/logrhythm` connector (which feeds LogRhythm lists from OpenCTI) to send IOCs out and bring cases in. +LogRhythm cases are case-management artifacts, so they are modeled as OpenCTI +Case-Incidents (a STIX Incident is reserved for alarms/detections). + Table of Contents - [OpenCTI LogRhythm Incidents Connector](#opencti-logrhythm-incidents-connector) @@ -96,5 +99,5 @@ python main.py ## Behavior On each run the connector fetches cases from the LogRhythm Case API (capped at -`max_cases`), converts each case to a STIX Incident and sends the bundle to -OpenCTI. OpenCTI deduplicates incidents by their deterministic id across runs. +`max_cases`), converts each case to a STIX Case-Incident and sends the bundle to +OpenCTI. OpenCTI deduplicates case-incidents by their deterministic id across runs. diff --git a/external-import/logrhythm-incidents/__metadata__/connector_manifest.json b/external-import/logrhythm-incidents/__metadata__/connector_manifest.json index 7adf87e0796..b85419172ac 100644 --- a/external-import/logrhythm-incidents/__metadata__/connector_manifest.json +++ b/external-import/logrhythm-incidents/__metadata__/connector_manifest.json @@ -1,8 +1,8 @@ { "title": "LogRhythm Incidents", "slug": "logrhythm-incidents", - "description": "The OpenCTI LogRhythm Incidents connector imports cases from LogRhythm SIEM into OpenCTI as STIX Incidents. It periodically pulls cases through the LogRhythm Case API and converts them to STIX 2.1 Incidents (with priority-based severity, timestamps and an external reference). Paired with the existing LogRhythm stream connector (which feeds LogRhythm lists from OpenCTI), it provides the import side of a bidirectional integration.", - "short_description": "Import LogRhythm cases into OpenCTI as STIX Incidents (bidirectional import side).", + "description": "The OpenCTI LogRhythm Incidents connector imports cases from LogRhythm SIEM into OpenCTI as STIX Case-Incidents. It periodically pulls cases through the LogRhythm Case API and converts them to STIX 2.1 Case-Incidents (with priority-based severity, timestamps and an external reference), since LogRhythm cases are case-management artifacts. Paired with the existing LogRhythm stream connector (which feeds LogRhythm lists from OpenCTI), it provides the import side of a bidirectional integration.", + "short_description": "Import LogRhythm cases into OpenCTI as STIX Case-Incidents (bidirectional import side).", "logo": null, "use_cases": [ "SIEM & Analytics" diff --git a/external-import/logrhythm-incidents/src/connector/connector.py b/external-import/logrhythm-incidents/src/connector/connector.py index a4b873d50b0..f13c01f826a 100644 --- a/external-import/logrhythm-incidents/src/connector/connector.py +++ b/external-import/logrhythm-incidents/src/connector/connector.py @@ -24,9 +24,9 @@ def __init__(self, config: ConnectorSettings, helper: OpenCTIConnectorHelper): def _collect_intelligence(self) -> list: stix_objects: list = [] for case in self.client.get_cases(): - incident = self.converter.create_incident(case) - if incident is not None: - stix_objects.append(incident) + case_incident = self.converter.create_case_incident(case) + if case_incident is not None: + stix_objects.append(case_incident) if stix_objects: stix_objects.append(self.converter.author) diff --git a/external-import/logrhythm-incidents/src/connector/converter_to_stix.py b/external-import/logrhythm-incidents/src/connector/converter_to_stix.py index 420244b8bff..9d78bc8c1b1 100644 --- a/external-import/logrhythm-incidents/src/connector/converter_to_stix.py +++ b/external-import/logrhythm-incidents/src/connector/converter_to_stix.py @@ -4,7 +4,7 @@ from typing import Optional import stix2 -from pycti import Identity, Incident, MarkingDefinition +from pycti import CaseIncident, CustomObjectCaseIncident, Identity, MarkingDefinition _TLP_MAPPING = { "clear": stix2.TLP_WHITE, @@ -17,6 +17,9 @@ # LogRhythm case priority is a 1-5 scale (5 = highest). _PRIORITY_MAPPING = {5: "critical", 4: "high", 3: "medium", 2: "low", 1: "low"} +# OpenCTI case priority derived from the severity. +_CASE_PRIORITY_MAPPING = {"critical": "P1", "high": "P2", "medium": "P3", "low": "P4"} + def _amber_strict() -> stix2.MarkingDefinition: return stix2.MarkingDefinition( @@ -89,8 +92,13 @@ def _map_severity(value) -> str: return _PRIORITY_MAPPING.get(int(value), "low") return "low" - def create_incident(self, case: dict) -> Optional[stix2.Incident]: - """Create a STIX Incident from a LogRhythm case dictionary.""" + def create_case_incident(self, case: dict) -> Optional[CustomObjectCaseIncident]: + """ + Create a STIX Case-Incident from a LogRhythm case dictionary. + + LogRhythm cases are case-management artifacts, so they map to an OpenCTI + Case-Incident (not an Incident, which is reserved for alarms/detections). + """ number = str(case.get("number") or case.get("id") or "").strip() name = case.get("name") or ( f"LogRhythm case {number}" if number else "LogRhythm case" @@ -106,18 +114,16 @@ def create_incident(self, case: dict) -> Optional[stix2.Incident]: if number: external_references = [{"source_name": "LogRhythm", "external_id": number}] - return stix2.Incident( - id=Incident.generate_id(name, created), + return CustomObjectCaseIncident( + id=CaseIncident.generate_id(name, created), name=name, description=description, + severity=severity, + priority=_CASE_PRIORITY_MAPPING.get(severity, "P4"), created=created, modified=modified, created_by_ref=self.author["id"], object_marking_refs=[self.tlp_marking], external_references=external_references, - custom_properties={ - "source": "LogRhythm", - "severity": severity, - "incident_type": "alert", - }, + object_refs=[], ) diff --git a/external-import/logrhythm-incidents/tests/test_connector.py b/external-import/logrhythm-incidents/tests/test_connector.py index f51b32b7fb8..5e39ffcd89a 100644 --- a/external-import/logrhythm-incidents/tests/test_connector.py +++ b/external-import/logrhythm-incidents/tests/test_connector.py @@ -18,7 +18,7 @@ def test_collect_intelligence_builds_objects(): objects = connector._collect_intelligence() types = [o["type"] for o in objects] - assert "incident" in types + assert "case-incident" in types assert "identity" in types # author appended diff --git a/external-import/logrhythm-incidents/tests/test_converter.py b/external-import/logrhythm-incidents/tests/test_converter.py index 40f44ab211c..b2499b4e8c3 100644 --- a/external-import/logrhythm-incidents/tests/test_converter.py +++ b/external-import/logrhythm-incidents/tests/test_converter.py @@ -36,9 +36,9 @@ def test_map_severity(value, expected): assert ConverterToStix._map_severity(value) == expected -def test_create_incident(): +def test_create_case_incident(): converter = _converter() - incident = converter.create_incident( + case = converter.create_case_incident( { "name": "Case A", "number": "42", @@ -47,12 +47,13 @@ def test_create_incident(): "summary": "Case details", } ) - assert incident["type"] == "incident" - assert incident["name"] == "Case A" - assert incident["external_references"][0]["external_id"] == "42" + assert case["type"] == "case-incident" + assert case["name"] == "Case A" + assert case["external_references"][0]["external_id"] == "42" + assert case["priority"] == "P1" -def test_create_incident_minimal(): +def test_create_case_incident_minimal(): converter = _converter() - incident = converter.create_incident({}) - assert incident["name"] == "LogRhythm case" + case = converter.create_case_incident({}) + assert case["name"] == "LogRhythm case" From 9ea9abf5e4ba6de6bbb8b0389cd2b449e97682f3 Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Mon, 15 Jun 2026 12:12:08 +0200 Subject: [PATCH 3/4] feat(logrhythm-incidents): import alarms as Incidents and cases as Case-Incidents (#6728) LogRhythm exposes two distinct concepts. Model them as two STIX entities: alarms attached to a case become STIX Incidents, and the case itself becomes a STIX Case-Incident that references those Incidents through object_refs. Adds get_case_alarms to the client, create_incident plus risk-score mapping to the converter, and a dual collection loop. Tests and docs updated. --- external-import/logrhythm-incidents/README.md | 26 ++++--- .../__metadata__/connector_manifest.json | 4 +- .../src/connector/connector.py | 16 ++++- .../src/connector/converter_to_stix.py | 72 ++++++++++++++++++- .../src/logrhythm_client/api_client.py | 22 +++++- .../logrhythm-incidents/tests/test_client.py | 23 ++++-- .../tests/test_connector.py | 13 +++- .../tests/test_converter.py | 33 +++++++++ 8 files changed, 186 insertions(+), 23 deletions(-) diff --git a/external-import/logrhythm-incidents/README.md b/external-import/logrhythm-incidents/README.md index 996f236a18b..27c3b3f422c 100644 --- a/external-import/logrhythm-incidents/README.md +++ b/external-import/logrhythm-incidents/README.md @@ -1,13 +1,18 @@ # OpenCTI LogRhythm Incidents Connector The LogRhythm Incidents connector is an **external-import** connector that pulls -cases from LogRhythm SIEM into OpenCTI as STIX **Case-Incidents**. It is the import +cases and their alarm evidence from LogRhythm SIEM into OpenCTI. It is the import side of a bidirectional integration: pair it with the existing `stream/logrhythm` connector (which feeds LogRhythm lists from OpenCTI) to send IOCs out and bring cases in. -LogRhythm cases are case-management artifacts, so they are modeled as OpenCTI -Case-Incidents (a STIX Incident is reserved for alarms/detections). +LogRhythm exposes two distinct concepts that map to two STIX entities: + +- **Alarms** (detections/alerts) attached to a case are modeled as STIX + **Incidents**. +- The **case** itself (a case-management artifact) is modeled as a STIX + **Case-Incident** that references the alarm Incidents it groups through its + `object_refs`. Table of Contents @@ -26,9 +31,11 @@ Table of Contents ## Introduction LogRhythm is a SIEM platform. This connector periodically queries the LogRhythm -Case API for cases and imports them into OpenCTI as STIX 2.1 Incidents, attributed -to a LogRhythm author identity and marked with a configurable TLP. The LogRhythm -case priority (1-5) is mapped to the OpenCTI incident severity. +Case API for cases and their alarm evidence, then imports them into OpenCTI as STIX +2.1 Case-Incidents (the cases) and Incidents (the alarms), attributed to a LogRhythm +author identity and marked with a configurable TLP. The LogRhythm case priority +(1-5) is mapped to the Case-Incident severity, and the alarm risk score (0-100) to +the Incident severity. ## Requirements @@ -99,5 +106,8 @@ python main.py ## Behavior On each run the connector fetches cases from the LogRhythm Case API (capped at -`max_cases`), converts each case to a STIX Case-Incident and sends the bundle to -OpenCTI. OpenCTI deduplicates case-incidents by their deterministic id across runs. +`max_cases`). For every case it also fetches the attached alarm evidence, converts +each alarm to a STIX Incident, and converts the case to a STIX Case-Incident that +references those Incidents through its `object_refs`. The resulting bundle is sent +to OpenCTI, which deduplicates both entity types by their deterministic id across +runs. diff --git a/external-import/logrhythm-incidents/__metadata__/connector_manifest.json b/external-import/logrhythm-incidents/__metadata__/connector_manifest.json index b85419172ac..4931c9d4782 100644 --- a/external-import/logrhythm-incidents/__metadata__/connector_manifest.json +++ b/external-import/logrhythm-incidents/__metadata__/connector_manifest.json @@ -1,8 +1,8 @@ { "title": "LogRhythm Incidents", "slug": "logrhythm-incidents", - "description": "The OpenCTI LogRhythm Incidents connector imports cases from LogRhythm SIEM into OpenCTI as STIX Case-Incidents. It periodically pulls cases through the LogRhythm Case API and converts them to STIX 2.1 Case-Incidents (with priority-based severity, timestamps and an external reference), since LogRhythm cases are case-management artifacts. Paired with the existing LogRhythm stream connector (which feeds LogRhythm lists from OpenCTI), it provides the import side of a bidirectional integration.", - "short_description": "Import LogRhythm cases into OpenCTI as STIX Case-Incidents (bidirectional import side).", + "description": "The OpenCTI LogRhythm Incidents connector imports cases and their alarm evidence from LogRhythm SIEM into OpenCTI. It periodically pulls cases through the LogRhythm Case API, converts each attached alarm (a detection) to a STIX 2.1 Incident, and converts the case (a case-management artifact) to a STIX 2.1 Case-Incident that references those Incidents through its object_refs. Paired with the existing LogRhythm stream connector (which feeds LogRhythm lists from OpenCTI), it provides the import side of a bidirectional integration.", + "short_description": "Import LogRhythm cases (Case-Incidents) and their alarms (Incidents) into OpenCTI (bidirectional import side).", "logo": null, "use_cases": [ "SIEM & Analytics" diff --git a/external-import/logrhythm-incidents/src/connector/connector.py b/external-import/logrhythm-incidents/src/connector/connector.py index f13c01f826a..7cf8c8a59ab 100644 --- a/external-import/logrhythm-incidents/src/connector/connector.py +++ b/external-import/logrhythm-incidents/src/connector/connector.py @@ -24,7 +24,21 @@ def __init__(self, config: ConnectorSettings, helper: OpenCTIConnectorHelper): def _collect_intelligence(self) -> list: stix_objects: list = [] for case in self.client.get_cases(): - case_incident = self.converter.create_case_incident(case) + case_id = case.get("id") or case.get("number") + + # LogRhythm alarms attached to the case are detections -> Incidents. + incident_ids = [] + for alarm in self.client.get_case_alarms(case_id): + incident = self.converter.create_incident(alarm) + if incident is not None: + stix_objects.append(incident) + incident_ids.append(incident["id"]) + + # The LogRhythm case itself is a case-management artifact -> Case-Incident, + # referencing the alarm Incidents it groups. + case_incident = self.converter.create_case_incident( + case, object_refs=incident_ids + ) if case_incident is not None: stix_objects.append(case_incident) diff --git a/external-import/logrhythm-incidents/src/connector/converter_to_stix.py b/external-import/logrhythm-incidents/src/connector/converter_to_stix.py index 9d78bc8c1b1..a4549c4a579 100644 --- a/external-import/logrhythm-incidents/src/connector/converter_to_stix.py +++ b/external-import/logrhythm-incidents/src/connector/converter_to_stix.py @@ -4,7 +4,13 @@ from typing import Optional import stix2 -from pycti import CaseIncident, CustomObjectCaseIncident, Identity, MarkingDefinition +from pycti import ( + CaseIncident, + CustomObjectCaseIncident, + Identity, + Incident, + MarkingDefinition, +) _TLP_MAPPING = { "clear": stix2.TLP_WHITE, @@ -92,7 +98,67 @@ def _map_severity(value) -> str: return _PRIORITY_MAPPING.get(int(value), "low") return "low" - def create_case_incident(self, case: dict) -> Optional[CustomObjectCaseIncident]: + @staticmethod + def _map_risk(value) -> str: + """Map a LogRhythm alarm risk score (0-100) to an OpenCTI severity.""" + try: + score = int(value) + except (TypeError, ValueError): + return "low" + if score >= 80: + return "critical" + if score >= 60: + return "high" + if score >= 40: + return "medium" + return "low" + + def create_incident(self, alarm: dict) -> Optional[stix2.Incident]: + """ + Create a STIX Incident from a LogRhythm alarm. + + LogRhythm alarms are detections/alerts, so they map to an OpenCTI Incident + (the case that groups them is modeled as a Case-Incident). + """ + alarm_id = str(alarm.get("alarmId") or alarm.get("id") or "").strip() + name = ( + alarm.get("alarmRuleName") + or alarm.get("name") + or (f"LogRhythm alarm {alarm_id}" if alarm_id else "LogRhythm alarm") + ) + created = self._to_iso( + alarm.get("alarmDate") + or alarm.get("dateInserted") + or alarm.get("dateCreated") + ) + severity = self._map_risk(alarm.get("riskScore")) + description = alarm.get("text") or "Alarm imported from LogRhythm." + + external_references = None + if alarm_id: + external_references = [ + {"source_name": "LogRhythm", "external_id": alarm_id} + ] + + return stix2.Incident( + id=Incident.generate_id(name, created), + name=name, + description=description, + created=created, + modified=created, + created_by_ref=self.author["id"], + object_marking_refs=[self.tlp_marking], + external_references=external_references, + custom_properties={ + "source": "LogRhythm", + "severity": severity, + "incident_type": "alert", + }, + ) + + def create_case_incident( + self, case: dict, object_refs=None + ) -> Optional[CustomObjectCaseIncident]: """ Create a STIX Case-Incident from a LogRhythm case dictionary. @@ -125,5 +191,5 @@ def create_case_incident(self, case: dict) -> Optional[CustomObjectCaseIncident] created_by_ref=self.author["id"], object_marking_refs=[self.tlp_marking], external_references=external_references, - object_refs=[], + object_refs=object_refs or [], ) diff --git a/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py b/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py index 5b50831688a..cc56a82dbc5 100644 --- a/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py +++ b/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py @@ -12,6 +12,7 @@ from connector.settings import ConnectorSettings CASES_PATH = "/lr-case-api/cases" +CASE_ALARMS_PATH = "/lr-case-api/cases/{case_id}/evidence/alarms" class LogRhythmClient: @@ -52,19 +53,34 @@ def get_cases(self) -> list: if response is None: return [] try: - return self._extract_cases(response.json()) + return self._extract_list( + response.json(), ("cases", "items", "data", "results") + ) except ValueError: self.helper.connector_logger.error( "[API] Unexpected LogRhythm cases response" ) return [] + def get_case_alarms(self, case_id) -> list: + """Fetch the alarm evidence attached to a LogRhythm case.""" + path = CASE_ALARMS_PATH.format(case_id=case_id) + response = self._request("get", path) + if response is None: + return [] + try: + return self._extract_list( + response.json(), ("alarms", "items", "data", "results") + ) + except ValueError: + return [] + @staticmethod - def _extract_cases(payload) -> list: + def _extract_list(payload, keys) -> list: if isinstance(payload, list): return payload if isinstance(payload, dict): - for key in ("cases", "items", "data", "results"): + for key in keys: value = payload.get(key) if isinstance(value, list): return value diff --git a/external-import/logrhythm-incidents/tests/test_client.py b/external-import/logrhythm-incidents/tests/test_client.py index cd38f34f489..db1c6d3e472 100644 --- a/external-import/logrhythm-incidents/tests/test_client.py +++ b/external-import/logrhythm-incidents/tests/test_client.py @@ -27,10 +27,25 @@ def _response(payload) -> MagicMock: return response -def test_extract_cases_variants(): - assert LogRhythmClient._extract_cases([{"id": 1}]) == [{"id": 1}] - assert LogRhythmClient._extract_cases({"cases": [{"id": 2}]}) == [{"id": 2}] - assert LogRhythmClient._extract_cases({"unexpected": 1}) == [] +def test_extract_list_variants(): + assert LogRhythmClient._extract_list([{"id": 1}], ("cases",)) == [{"id": 1}] + assert LogRhythmClient._extract_list({"cases": [{"id": 2}]}, ("cases",)) == [ + {"id": 2} + ] + assert LogRhythmClient._extract_list({"alarms": [{"id": 3}]}, ("alarms",)) == [ + {"id": 3} + ] + assert LogRhythmClient._extract_list({"unexpected": 1}, ("cases",)) == [] + + +def test_get_case_alarms(): + client = _make_client() + client.session.request.return_value = _response([{"alarmId": "a1"}]) + + alarms = client.get_case_alarms("c1") + assert alarms == [{"alarmId": "a1"}] + call = client.session.request.call_args + assert call.args[1].endswith("/lr-case-api/cases/c1/evidence/alarms") def test_get_cases_happy_path(): diff --git a/external-import/logrhythm-incidents/tests/test_connector.py b/external-import/logrhythm-incidents/tests/test_connector.py index 5e39ffcd89a..af05b68c655 100644 --- a/external-import/logrhythm-incidents/tests/test_connector.py +++ b/external-import/logrhythm-incidents/tests/test_connector.py @@ -13,14 +13,22 @@ def _make_connector(): def test_collect_intelligence_builds_objects(): connector, _, client = _make_connector() - client.get_cases.return_value = [{"name": "Case A", "number": "1"}] + client.get_cases.return_value = [{"name": "Case A", "number": "1", "id": "c1"}] + client.get_case_alarms.return_value = [ + {"alarmId": "a1", "alarmRuleName": "Brute force", "riskScore": 85} + ] objects = connector._collect_intelligence() types = [o["type"] for o in objects] - assert "case-incident" in types + assert "incident" in types # alarm -> Incident + assert "case-incident" in types # case -> Case-Incident assert "identity" in types # author appended + case = next(o for o in objects if o["type"] == "case-incident") + incident = next(o for o in objects if o["type"] == "incident") + assert incident["id"] in case["object_refs"] + def test_collect_intelligence_empty(): connector, _, client = _make_connector() @@ -35,6 +43,7 @@ def test_process_message_sends_bundle(): helper.api.work.initiate_work.return_value = "work-1" helper.stix2_create_bundle.return_value = "bundle" client.get_cases.return_value = [{"name": "Case A", "number": "1"}] + client.get_case_alarms.return_value = [] connector.process_message() diff --git a/external-import/logrhythm-incidents/tests/test_converter.py b/external-import/logrhythm-incidents/tests/test_converter.py index b2499b4e8c3..a39f04bc738 100644 --- a/external-import/logrhythm-incidents/tests/test_converter.py +++ b/external-import/logrhythm-incidents/tests/test_converter.py @@ -36,6 +36,39 @@ def test_map_severity(value, expected): assert ConverterToStix._map_severity(value) == expected +@pytest.mark.parametrize( + "value, expected", + [(85, "critical"), (65, "high"), (45, "medium"), (10, "low"), (None, "low")], +) +def test_map_risk(value, expected): + assert ConverterToStix._map_risk(value) == expected + + +def test_create_incident_from_alarm(): + converter = _converter() + incident = converter.create_incident( + { + "alarmRuleName": "Brute force", + "alarmId": "a1", + "riskScore": 85, + "alarmDate": "2024-05-01T00:00:00Z", + } + ) + assert incident["type"] == "incident" + assert incident["name"] == "Brute force" + assert incident["external_references"][0]["external_id"] == "a1" + assert incident["incident_type"] == "alert" + + +def test_create_case_incident_with_object_refs(): + converter = _converter() + incident = converter.create_incident({"alarmId": "a1", "riskScore": 85}) + case = converter.create_case_incident( + {"name": "Case A", "number": "42"}, object_refs=[incident["id"]] + ) + assert incident["id"] in case["object_refs"] + + def test_create_case_incident(): converter = _converter() case = converter.create_case_incident( From 0625aaebd3b1937370f288621b59c9523e59e18f Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Mon, 15 Jun 2026 16:19:38 +0200 Subject: [PATCH 4/4] fix(logrhythm-incidents): correct TLP:CLEAR, dedupe ids, harden client and docs Emit a distinct OpenCTI TLP:CLEAR marking for tlp_level='clear' instead of aliasing stix2.TLP_WHITE, and build TLP:AMBER+STRICT the same way via a shared helper. Use a deterministic epoch fallback for missing/invalid timestamps so Incident.generate_id / CaseIncident.generate_id stay stable and re-runs do not create duplicate objects. _request now fails fast on non-retriable 4xx (401/403/404) - only 429 and network/5xx errors are retried - and logs context via meta={...}. The connector marks its work to_processed in a finally so a failed run does not leave an "in progress" work item hanging, and its docstring now reflects Case-Incidents. Docs: mark scope / CONNECTOR_SCOPE as required in config.yml.sample, docker-compose.yml and README.md; add 'white' to the tlp_level option list; normalise the compose placeholders to ChangeMe. Tests: align the _load_config_dict overrides with the SDK (-> Self), assert on str(err.value), fix a grammar typo, and add TLP:CLEAR / deterministic-id / 4xx-fail-fast tests. --- external-import/logrhythm-incidents/README.md | 2 +- .../logrhythm-incidents/docker-compose.yml | 10 ++-- .../logrhythm-incidents/src/config.yml.sample | 4 +- .../src/connector/connector.py | 13 +++-- .../src/connector/converter_to_stix.py | 26 +++++++--- .../src/logrhythm_client/api_client.py | 47 +++++++++++++++---- .../tests/test-requirements.txt | 2 +- .../logrhythm-incidents/tests/test_client.py | 28 +++++++++++ .../tests/test_converter.py | 24 ++++++++++ .../logrhythm-incidents/tests/test_main.py | 4 +- .../tests/tests_connector/test_settings.py | 8 ++-- 11 files changed, 133 insertions(+), 35 deletions(-) diff --git a/external-import/logrhythm-incidents/README.md b/external-import/logrhythm-incidents/README.md index 27c3b3f422c..0ee46653ef5 100644 --- a/external-import/logrhythm-incidents/README.md +++ b/external-import/logrhythm-incidents/README.md @@ -62,7 +62,7 @@ environment variables. | --------------- | ----------------- | ---------------------------- | --------------------- | --------- | --------------------------------------------------- | | Connector ID | `id` | `CONNECTOR_ID` | / | Yes | A unique `UUIDv4` identifier for this connector. | | Connector Name | `name` | `CONNECTOR_NAME` | `LogRhythm Incidents` | No | Name of the connector. | -| Connector Scope | `scope` | `CONNECTOR_SCOPE` | `logrhythm` | No | The scope of the connector. | +| Connector Scope | `scope` | `CONNECTOR_SCOPE` | / | Yes | The scope of the connector. | | Log Level | `log_level` | `CONNECTOR_LOG_LEVEL` | `error` | No | Logs verbosity (`debug`, `info`, `warn`, `error`). | | Duration Period | `duration_period` | `CONNECTOR_DURATION_PERIOD` | `PT15M` | No | ISO-8601 period between two runs. | diff --git a/external-import/logrhythm-incidents/docker-compose.yml b/external-import/logrhythm-incidents/docker-compose.yml index fc0be617023..770377a27cc 100644 --- a/external-import/logrhythm-incidents/docker-compose.yml +++ b/external-import/logrhythm-incidents/docker-compose.yml @@ -5,16 +5,16 @@ services: environment: # Generic parameters (connection with OpenCTI) - OPENCTI_URL=http://localhost - - OPENCTI_TOKEN=CHANGEME + - OPENCTI_TOKEN=ChangeMe # Common parameters for connectors of type EXTERNAL_IMPORT - - CONNECTOR_ID=CHANGEME + - CONNECTOR_ID=ChangeMe - CONNECTOR_NAME=LogRhythm Incidents # optional (default: 'LogRhythm Incidents') - - CONNECTOR_SCOPE=logrhythm # optional + - CONNECTOR_SCOPE=logrhythm # required - CONNECTOR_LOG_LEVEL=error # optional (default: 'error') - CONNECTOR_DURATION_PERIOD=PT15M # optional (default: 'PT15M') # LogRhythm parameters - - LOGRHYTHM_INCIDENTS_API_BASE_URL=CHANGEME # e.g. https://logrhythm.example.com:8501 - - LOGRHYTHM_INCIDENTS_API_TOKEN=CHANGEME + - LOGRHYTHM_INCIDENTS_API_BASE_URL=ChangeMe # e.g. https://logrhythm.example.com:8501 + - LOGRHYTHM_INCIDENTS_API_TOKEN=ChangeMe - LOGRHYTHM_INCIDENTS_MAX_CASES=200 # optional (default: 200) - LOGRHYTHM_INCIDENTS_TLP_LEVEL=amber # optional (default: 'amber') - LOGRHYTHM_INCIDENTS_SSL_VERIFY=true # optional (default: true) diff --git a/external-import/logrhythm-incidents/src/config.yml.sample b/external-import/logrhythm-incidents/src/config.yml.sample index 8d6708ce6b1..21d6c6241b8 100644 --- a/external-import/logrhythm-incidents/src/config.yml.sample +++ b/external-import/logrhythm-incidents/src/config.yml.sample @@ -6,7 +6,7 @@ connector: id: 'ChangeMe' type: 'EXTERNAL_IMPORT' name: 'LogRhythm Incidents' # optional (default: 'LogRhythm Incidents') - scope: 'logrhythm' # optional + scope: 'logrhythm' # required log_level: 'error' # optional (default: 'error') duration_period: 'PT15M' # optional (default: 'PT15M') @@ -14,5 +14,5 @@ logrhythm_incidents: api_base_url: 'ChangeMe' # Base URL of the LogRhythm API gateway, e.g. https://logrhythm.example.com:8501 api_token: 'ChangeMe' # LogRhythm API token (Bearer) max_cases: 200 # optional (default: 200) - tlp_level: 'amber' # optional, one of clear/green/amber/amber+strict/red (default: 'amber') + tlp_level: 'amber' # optional, one of clear/white/green/amber/amber+strict/red (default: 'amber') ssl_verify: true # optional (default: true) diff --git a/external-import/logrhythm-incidents/src/connector/connector.py b/external-import/logrhythm-incidents/src/connector/connector.py index 7cf8c8a59ab..03a4da05934 100644 --- a/external-import/logrhythm-incidents/src/connector/connector.py +++ b/external-import/logrhythm-incidents/src/connector/connector.py @@ -10,7 +10,7 @@ class LogRhythmIncidentsConnector: """ External-import connector that pulls LogRhythm cases into OpenCTI as STIX - Incidents. + Case-Incidents, with the alarms attached to each case imported as Incidents. """ def __init__(self, config: ConnectorSettings, helper: OpenCTIConnectorHelper): @@ -51,6 +51,7 @@ def process_message(self) -> None: self.helper.connector_logger.info( "[CONNECTOR] Starting LogRhythm Incidents connector..." ) + work_id = None try: now = datetime.now(timezone.utc) current_state = self.helper.get_state() or {} @@ -68,14 +69,18 @@ def process_message(self) -> None: current_state["last_run"] = now.isoformat() self.helper.set_state(current_state) - self.helper.api.work.to_processed( - work_id, "LogRhythm Incidents connector successfully run" - ) except (KeyboardInterrupt, SystemExit): self.helper.connector_logger.info("[CONNECTOR] Connector stopped...") sys.exit(0) except Exception as err: self.helper.connector_logger.error(str(err)) + finally: + # Always close the work so a failed run does not leave an + # "in progress" work item hanging in OpenCTI. + if work_id is not None: + self.helper.api.work.to_processed( + work_id, "LogRhythm Incidents connector run completed" + ) def run(self) -> None: self.helper.schedule_process( diff --git a/external-import/logrhythm-incidents/src/connector/converter_to_stix.py b/external-import/logrhythm-incidents/src/connector/converter_to_stix.py index a4549c4a579..3ebc363195d 100644 --- a/external-import/logrhythm-incidents/src/connector/converter_to_stix.py +++ b/external-import/logrhythm-incidents/src/connector/converter_to_stix.py @@ -12,8 +12,11 @@ MarkingDefinition, ) +# TLP:CLEAR and TLP:AMBER+STRICT are distinct OpenCTI markings (custom statement +# markings carrying x_opencti_definition), not aliases of the STIX markings, so +# they are built explicitly in _custom_tlp(). The plain STIX markings cover the +# rest. _TLP_MAPPING = { - "clear": stix2.TLP_WHITE, "white": stix2.TLP_WHITE, "green": stix2.TLP_GREEN, "amber": stix2.TLP_AMBER, @@ -26,15 +29,22 @@ # OpenCTI case priority derived from the severity. _CASE_PRIORITY_MAPPING = {"critical": "P1", "high": "P2", "medium": "P3", "low": "P4"} +# Deterministic fallback for missing/invalid timestamps. Incident.generate_id and +# CaseIncident.generate_id derive the STIX id from (name, created), so a +# datetime.now() fallback would mint a new id - and a duplicate object - on every +# run for records that carry no usable timestamp. A fixed epoch anchor keeps the +# id stable. +_FALLBACK_DT = datetime(1970, 1, 1, tzinfo=timezone.utc) -def _amber_strict() -> stix2.MarkingDefinition: + +def _custom_tlp(definition: str) -> stix2.MarkingDefinition: return stix2.MarkingDefinition( - id=MarkingDefinition.generate_id("TLP", "TLP:AMBER+STRICT"), + id=MarkingDefinition.generate_id("TLP", definition), definition_type="statement", definition={"statement": "custom"}, custom_properties={ "x_opencti_definition_type": "TLP", - "x_opencti_definition": "TLP:AMBER+STRICT", + "x_opencti_definition": definition, }, ) @@ -46,7 +56,9 @@ def __init__(self, helper, tlp_level: str): self.helper = helper self.author = self._create_author() if tlp_level == "amber+strict": - self.tlp_marking = _amber_strict() + self.tlp_marking = _custom_tlp("TLP:AMBER+STRICT") + elif tlp_level == "clear": + self.tlp_marking = _custom_tlp("TLP:CLEAR") else: self.tlp_marking = _TLP_MAPPING.get(tlp_level, stix2.TLP_AMBER) @@ -66,7 +78,7 @@ def _to_iso(value) -> str: timestamp string (millisecond precision, ``Z`` suffix). """ if value is None or value == "": - dt = datetime.now(timezone.utc) + dt = _FALLBACK_DT elif isinstance(value, (int, float)) or ( isinstance(value, str) and value.isdigit() ): @@ -83,7 +95,7 @@ def _to_iso(value) -> str: else dt.astimezone(timezone.utc) ) except ValueError: - dt = datetime.now(timezone.utc) + dt = _FALLBACK_DT return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" @staticmethod diff --git a/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py b/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py index cc56a82dbc5..44b090d5c39 100644 --- a/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py +++ b/external-import/logrhythm-incidents/src/logrhythm_client/api_client.py @@ -87,23 +87,52 @@ def _extract_list(payload, keys) -> list: return [] def _request(self, method: str, path: str, **kwargs) -> Optional[requests.Response]: - """Perform an HTTP request with retry/backoff on rate limiting and transient errors.""" + """ + Perform an HTTP request with retry/backoff. + + Connection/timeout errors, rate limiting (429) and server-side errors + (5xx) are retried; other 4xx responses (e.g. 401/403/404) fail fast + without retrying, since retrying them only adds delay and log noise. + Structured context is passed via ``meta={...}``. + """ url = f"{self._base_url}{path}" for attempt in range(self.REQUEST_ATTEMPTS): + last_attempt = attempt == self.REQUEST_ATTEMPTS - 1 try: response = self.session.request( method, url, timeout=self.TIMEOUT, **kwargs ) - if response.status_code == 429 and attempt < self.REQUEST_ATTEMPTS - 1: - time.sleep(self.BACKOFF_FACTOR * (2**attempt)) - continue - response.raise_for_status() - return response except requests.RequestException as err: self.helper.connector_logger.warning( "[API] LogRhythm request failed", - {"url": url, "error": str(err)}, + meta={"url": url, "error_type": type(err).__name__}, + ) + if last_attempt: + return None + time.sleep(self.BACKOFF_FACTOR * (2**attempt)) + continue + + if response.status_code == 429 or response.status_code >= 500: + if last_attempt: + self.helper.connector_logger.warning( + "[API] LogRhythm request failed", + meta={"url": url, "status_code": response.status_code}, + ) + return None + time.sleep(self.BACKOFF_FACTOR * (2**attempt)) + continue + + try: + response.raise_for_status() + except requests.HTTPError as err: + self.helper.connector_logger.warning( + "[API] LogRhythm request failed", + meta={ + "url": url, + "status_code": response.status_code, + "error_type": type(err).__name__, + }, ) - if attempt < self.REQUEST_ATTEMPTS - 1: - time.sleep(self.BACKOFF_FACTOR * (2**attempt)) + return None + return response return None diff --git a/external-import/logrhythm-incidents/tests/test-requirements.txt b/external-import/logrhythm-incidents/tests/test-requirements.txt index 38f23c0ab30..a6376704fcc 100644 --- a/external-import/logrhythm-incidents/tests/test-requirements.txt +++ b/external-import/logrhythm-incidents/tests/test-requirements.txt @@ -1,3 +1,3 @@ -# Main dependencies needs to be installed +# Main dependencies need to be installed -r ../src/requirements.txt pytest==9.0.3 diff --git a/external-import/logrhythm-incidents/tests/test_client.py b/external-import/logrhythm-incidents/tests/test_client.py index db1c6d3e472..ff3b11b9f30 100644 --- a/external-import/logrhythm-incidents/tests/test_client.py +++ b/external-import/logrhythm-incidents/tests/test_client.py @@ -79,3 +79,31 @@ def test_request_retries_on_rate_limit(): assert result is not None assert client.session.request.call_count == 2 sleep.assert_called_once() + + +def test_request_fails_fast_on_4xx_without_retry(): + client = _make_client() + unauthorized = MagicMock() + unauthorized.status_code = 401 + unauthorized.raise_for_status.side_effect = requests.HTTPError("401") + client.session.request.return_value = unauthorized + + with patch("logrhythm_client.api_client.time.sleep") as sleep: + assert client._request("get", "/lr-case-api/cases") is None + + # No retry on a non-retriable 4xx: a single call and no backoff sleep. + assert client.session.request.call_count == 1 + sleep.assert_not_called() + + +def test_request_retries_on_server_error(): + client = _make_client() + server_error = MagicMock() + server_error.status_code = 503 + client.session.request.side_effect = [server_error, _response([])] + + with patch("logrhythm_client.api_client.time.sleep") as sleep: + assert client._request("get", "/lr-case-api/cases") is not None + + assert client.session.request.call_count == 2 + sleep.assert_called_once() diff --git a/external-import/logrhythm-incidents/tests/test_converter.py b/external-import/logrhythm-incidents/tests/test_converter.py index a39f04bc738..780533a565b 100644 --- a/external-import/logrhythm-incidents/tests/test_converter.py +++ b/external-import/logrhythm-incidents/tests/test_converter.py @@ -19,6 +19,30 @@ def test_amber_strict_marking(): assert converter.tlp_marking["definition_type"] == "statement" +def test_clear_marking_is_distinct_from_white(): + # TLP:CLEAR must be its own OpenCTI statement marking, not the STIX TLP:WHITE. + clear = _converter("clear").tlp_marking + white = _converter("white").tlp_marking + assert clear["definition_type"] == "statement" + assert clear["x_opencti_definition"] == "TLP:CLEAR" + assert white.get("x_opencti_definition") != "TLP:CLEAR" + + +def test_to_iso_uses_deterministic_fallback(): + # Missing / invalid timestamps must yield a stable anchor so the generated + # Incident / Case-Incident id does not change across runs (avoids duplicates). + assert ConverterToStix._to_iso(None) == ConverterToStix._to_iso("") + assert ConverterToStix._to_iso("not-a-date") == ConverterToStix._to_iso(None) + assert ConverterToStix._to_iso(None).startswith("1970-01-01") + + +def test_case_incident_id_is_stable_without_timestamp(): + converter = _converter() + first = converter.create_case_incident({"number": "42"}) + second = converter.create_case_incident({"number": "42"}) + assert first["id"] == second["id"] + + @pytest.mark.parametrize( "value, expected", [ diff --git a/external-import/logrhythm-incidents/tests/test_main.py b/external-import/logrhythm-incidents/tests/test_main.py index 60c6672f509..cbdc80f2ea5 100644 --- a/external-import/logrhythm-incidents/tests/test_main.py +++ b/external-import/logrhythm-incidents/tests/test_main.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Self from unittest.mock import MagicMock import pytest @@ -24,7 +24,7 @@ class StubConnectorSettings(ConnectorSettings): """Subclass of `ConnectorSettings` returning a fake but valid config dict.""" @classmethod - def _load_config_dict(cls, _, handler) -> dict[str, Any]: + def _load_config_dict(cls, _, handler) -> Self: return handler( { "opencti": { diff --git a/external-import/logrhythm-incidents/tests/tests_connector/test_settings.py b/external-import/logrhythm-incidents/tests/tests_connector/test_settings.py index def9e5c590a..28596c79a44 100644 --- a/external-import/logrhythm-incidents/tests/tests_connector/test_settings.py +++ b/external-import/logrhythm-incidents/tests/tests_connector/test_settings.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Self import pytest from connector import ConnectorSettings @@ -25,7 +25,7 @@ def _valid_settings() -> dict[str, Any]: def test_settings_should_accept_valid_input(): class FakeConnectorSettings(ConnectorSettings): @classmethod - def _load_config_dict(cls, _, handler) -> dict[str, Any]: + def _load_config_dict(cls, _, handler) -> Self: return handler(_valid_settings()) settings = FakeConnectorSettings() @@ -58,9 +58,9 @@ def _load_config_dict(cls, _, handler) -> dict[str, Any]: def test_settings_should_raise_when_invalid_input(settings_dict): class FakeConnectorSettings(ConnectorSettings): @classmethod - def _load_config_dict(cls, _, handler) -> dict[str, Any]: + def _load_config_dict(cls, _, handler) -> Self: return handler(settings_dict) with pytest.raises(ConfigValidationError) as err: FakeConnectorSettings() - assert str("Error validating configuration") in str(err) + assert "Error validating configuration" in str(err.value)