diff --git a/internal-enrichment/qualys-cve-enrichment/__metadata__/CONNECTOR_CONFIG_DOC.md b/internal-enrichment/qualys-cve-enrichment/__metadata__/CONNECTOR_CONFIG_DOC.md index 32dfe4b8878..6c0790be15a 100644 --- a/internal-enrichment/qualys-cve-enrichment/__metadata__/CONNECTOR_CONFIG_DOC.md +++ b/internal-enrichment/qualys-cve-enrichment/__metadata__/CONNECTOR_CONFIG_DOC.md @@ -7,7 +7,7 @@ Below is an exhaustive enumeration of all configurable parameters available, eac | 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. | +| OPENCTI_TOKEN | `string` | ✅ | Format: [`password`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | | The API token to connect to OpenCTI. | | QUALYS_CVE_ENRICHMENT_USERNAME | `string` | ✅ | string | | Qualys API username. | | QUALYS_CVE_ENRICHMENT_PASSWORD | `string` | ✅ | Format: [`password`](https://json-schema.org/understanding-json-schema/reference/string#built-in-formats) | | Qualys API password. | | CONNECTOR_NAME | `string` | | string | `"Qualys CVE Enrichment"` | The name of the connector. | diff --git a/internal-enrichment/qualys-cve-enrichment/__metadata__/connector_config_schema.json b/internal-enrichment/qualys-cve-enrichment/__metadata__/connector_config_schema.json index e1e98b98754..e42a5334fd7 100644 --- a/internal-enrichment/qualys-cve-enrichment/__metadata__/connector_config_schema.json +++ b/internal-enrichment/qualys-cve-enrichment/__metadata__/connector_config_schema.json @@ -12,7 +12,9 @@ }, "OPENCTI_TOKEN": { "description": "The API token to connect to OpenCTI.", - "type": "string" + "format": "password", + "type": "string", + "writeOnly": true }, "CONNECTOR_NAME": { "default": "Qualys CVE Enrichment", diff --git a/internal-enrichment/qualys-cve-enrichment/__metadata__/connector_manifest.json b/internal-enrichment/qualys-cve-enrichment/__metadata__/connector_manifest.json index d7f35dc3677..01ab325bb91 100644 --- a/internal-enrichment/qualys-cve-enrichment/__metadata__/connector_manifest.json +++ b/internal-enrichment/qualys-cve-enrichment/__metadata__/connector_manifest.json @@ -20,7 +20,7 @@ "support_version": ">=7.260529.0", "subscription_link": null, "source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/internal-enrichment/qualys-cve-enrichment", - "manager_supported": false, + "manager_supported": true, "container_version": "rolling", "container_image": "opencti/connector-qualys-cve-enrichment", "container_type": "INTERNAL_ENRICHMENT" diff --git a/internal-enrichment/qualys-cve-enrichment/src/__init__.py b/internal-enrichment/qualys-cve-enrichment/src/__init__.py new file mode 100644 index 00000000000..213624daa71 --- /dev/null +++ b/internal-enrichment/qualys-cve-enrichment/src/__init__.py @@ -0,0 +1,20 @@ +"""Expose ``ConnectorSettings`` to the manager-supported config schema generator. + +At runtime the *contents* of ``src/`` are the working directory (see the +Dockerfile), so the connector imports its own modules top-level, e.g. +``from connector.settings import ConnectorSettings``. + +The config schema generator instead runs from the connector root and imports +``from src import ConnectorSettings``. Adding this package's own directory to +``sys.path`` lets the connector's existing top-level import style resolve in +that context too, without changing any runtime behaviour. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from connector.settings import ConnectorSettings # isort: skip + +__all__ = ["ConnectorSettings"] diff --git a/internal-enrichment/qualys-cve-enrichment/src/config.yml.sample b/internal-enrichment/qualys-cve-enrichment/src/config.yml.sample index d3bb7c946b4..1c5efd9761f 100644 --- a/internal-enrichment/qualys-cve-enrichment/src/config.yml.sample +++ b/internal-enrichment/qualys-cve-enrichment/src/config.yml.sample @@ -1,47 +1,47 @@ # OpenCTI Qualys CVE Enrichment Connector Configuration -# Copy this file to config.yml and update the values +# Copy this file to config.yml and update the values. +# +# Commented-out keys are optional: their defaults are defined in +# `src/connector/settings.py`, which is the single source of truth. +# Uncomment a key only to override the default. opencti: - # OpenCTI platform URL - url: 'http://localhost:8080' - # OpenCTI API token (from Settings > Security > Users) - token: 'changeme' + # OpenCTI platform URL (required) + url: 'ChangeMe' + # OpenCTI API token, from Settings > Security > Users (required) + token: 'ChangeMe' connector: - # Unique connector ID (UUID v4 — generate your own, e.g. with `uuidgen`) - id: 'b6e2f0a4-3c5d-4e9a-8f1b-2d7c9a0e5f13' - # Connector type (do not change) - type: 'INTERNAL_ENRICHMENT' + # Unique connector ID, a UUID v4 (generate your own, e.g. with `uuidgen`) + id: 'ChangeMe' # Display name in OpenCTI - name: 'Qualys CVE Enrichment' - # Entity types to enrich (Vulnerability only) - scope: 'Vulnerability' - # Confidence level for enrichment data (0-100) - confidence_level: 80 + # name: 'Qualys CVE Enrichment' + # Entity types to enrich + # scope: 'vulnerability' # Log level: debug, info, warning, error - log_level: 'info' + # log_level: 'info' # Auto-enrich on data creation (use with caution - can consume API quota) - auto: false + # auto: false qualys_cve_enrichment: - # Qualys API base URL + # Qualys API base URL. # Common platforms: # US Platform 1: https://qualysapi.qualys.com # US Platform 2: https://qualysapi.qg2.apps.qualys.com # EU Platform: https://qualysapi.qg3.apps.qualys.eu - base_url: 'https://qualysapi.qualys.com' + # base_url: 'https://qualysapi.qualys.com' - # Qualys credentials (account with API access) - username: 'changeme' - password: 'changeme' + # Qualys credentials, an account with API access (required) + username: 'ChangeMe' + password: 'ChangeMe' # SSL certificate verification (set to false for self-signed certs) - ssl_verify: true + # ssl_verify: true # API mode: 'v2' for legacy XML APIs (/api/2.0/ and /api/3.0/), # 'modern' for QPS/VMDR JSON APIs (/qps/rest/2.0/ and /rest/2.0/) - api_version: 'v2' + # api_version: 'v2' # Maximum TLP of an entity the connector will send to the Qualys API. # One of: clear, white, green, amber, amber+strict, red - max_tlp: 'amber+strict' + # max_tlp: 'amber+strict' diff --git a/internal-enrichment/qualys-cve-enrichment/src/requirements.txt b/internal-enrichment/qualys-cve-enrichment/src/requirements.txt index c390a904f9b..fa189b23691 100644 --- a/internal-enrichment/qualys-cve-enrichment/src/requirements.txt +++ b/internal-enrichment/qualys-cve-enrichment/src/requirements.txt @@ -6,6 +6,9 @@ pycti==7.260817.0 # OpenCTI connectors SDK connectors-sdk @ git+https://github.com/OpenCTI-Platform/connectors.git@master#subdirectory=connectors-sdk +# Configuration validation (manager-supported settings) +pydantic >=2.8.2, <3 + # HTTP requests library requests==2.33.0 diff --git a/internal-enrichment/qualys-cve-enrichment/tests/test_connector/test_settings.py b/internal-enrichment/qualys-cve-enrichment/tests/test_connector/test_settings.py index 4bc06d093fe..e1f06b3552d 100644 --- a/internal-enrichment/qualys-cve-enrichment/tests/test_connector/test_settings.py +++ b/internal-enrichment/qualys-cve-enrichment/tests/test_connector/test_settings.py @@ -1,9 +1,46 @@ """Tests for Qualys CVE Enrichment connector settings.""" +from typing import Any +from uuid import UUID + import pytest from connector.settings import ConnectorSettings from connectors_sdk import ConfigValidationError +FULL_VALID_SETTINGS_DICT: dict[str, Any] = { + "opencti": { + "url": "http://localhost:8080", + "token": "test-token-00000000-0000-0000-0000-000000000000", + }, + "connector": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Qualys CVE Enrichment", + "scope": "vulnerability", + "log_level": "info", + "auto": False, + }, + "qualys_cve_enrichment": { + "base_url": "https://qualysapi.qualys.com", + "username": "test-username", + "password": "test-password", + "ssl_verify": True, + "api_version": "v2", + "max_tlp": "amber+strict", + }, +} + +MINIMAL_VALID_SETTINGS_DICT: dict[str, Any] = { + "opencti": { + "url": "http://localhost:8080", + "token": "test-token-00000000-0000-0000-0000-000000000000", + }, + "connector": {}, + "qualys_cve_enrichment": { + "username": "test-username", + "password": "test-password", + }, +} + class TestConnectorSettingsInstantiation: """Test that ConnectorSettings can be created with valid env vars.""" @@ -104,3 +141,107 @@ def test_missing_password(self, monkeypatch): monkeypatch.delenv("QUALYS_CVE_ENRICHMENT_PASSWORD") with pytest.raises(ConfigValidationError): ConnectorSettings() + + +class TestSettingsValidationFromConfigDict: + """Validate settings from an explicit config dict. + + These tests bypass env/config-var loading entirely by overriding + ``_load_config_dict``, so they are independent of the ambient environment. + """ + + @staticmethod + def _settings_from(config_dict: dict[str, Any]) -> ConnectorSettings: + class FakeConnectorSettings(ConnectorSettings): + @classmethod + def _load_config_dict(cls, _, handler) -> Any: + return handler(config_dict) + + return FakeConnectorSettings() + + @pytest.mark.parametrize( + "config_dict", + [ + pytest.param(FULL_VALID_SETTINGS_DICT, id="full_valid_settings_dict"), + pytest.param(MINIMAL_VALID_SETTINGS_DICT, id="minimal_valid_settings_dict"), + ], + ) + def test_settings_should_accept_valid_input(self, config_dict): + settings = self._settings_from(config_dict) + + assert ( + settings.opencti.token.get_secret_value() + == "test-token-00000000-0000-0000-0000-000000000000" + ) + assert settings.connector.type == "INTERNAL_ENRICHMENT" + assert settings.qualys_cve_enrichment.username == "test-username" + assert ( + settings.qualys_cve_enrichment.password.get_secret_value() + == "test-password" + ) + assert isinstance(settings.to_helper_config(), dict) + + def test_minimal_settings_should_apply_declared_defaults(self): + """Omitted optional fields fall back to the defaults in settings.py.""" + settings = self._settings_from(MINIMAL_VALID_SETTINGS_DICT) + + assert settings.connector.name == "Qualys CVE Enrichment" + assert settings.qualys_cve_enrichment.ssl_verify is True + assert settings.qualys_cve_enrichment.api_version == "v2" + assert settings.qualys_cve_enrichment.max_tlp == "amber+strict" + + def test_settings_should_default_connector_id(self): + """The connector id falls back on its unique default UUID v4.""" + settings = self._settings_from(MINIMAL_VALID_SETTINGS_DICT) + + assert settings.connector.id == "061407e3-c49c-4c4d-94ef-88da16dbeb52" + assert UUID(settings.connector.id).version == 4 + + @pytest.mark.parametrize( + "config_dict", + [ + pytest.param({}, id="empty_settings_dict"), + pytest.param( + { + "opencti": {"url": "http://localhost:8080"}, + "connector": {}, + "qualys_cve_enrichment": { + "username": "test-username", + "password": "test-password", + }, + }, + id="missing_opencti_token", + ), + pytest.param( + { + "opencti": { + "url": "http://localhost:8080", + "token": "test-token", + }, + "connector": {}, + "qualys_cve_enrichment": { + "username": "test-username", + "password": "test-password", + "api_version": "not-a-valid-version", + }, + }, + id="invalid_api_version", + ), + pytest.param( + { + "opencti": { + "url": "http://localhost:8080", + "token": "test-token", + }, + "connector": {}, + "qualys_cve_enrichment": {"username": "test-username"}, + }, + id="missing_qualys_password", + ), + ], + ) + def test_settings_should_raise_when_invalid_input(self, config_dict): + with pytest.raises(ConfigValidationError) as exc_info: + self._settings_from(config_dict) + + assert "Error validating configuration" in str(exc_info.value) diff --git a/internal-enrichment/qualys-cve-enrichment/tests/test_main.py b/internal-enrichment/qualys-cve-enrichment/tests/test_main.py index aee1f889289..96b407cb63f 100644 --- a/internal-enrichment/qualys-cve-enrichment/tests/test_main.py +++ b/internal-enrichment/qualys-cve-enrichment/tests/test_main.py @@ -1,8 +1,60 @@ -"""Test that the connector entry point is importable.""" +"""Test that the connector entry point is importable and correctly wired. + +The wiring tests below cover the "manager-supported" contract: configuration is +loaded through Pydantic ``ConnectorSettings`` and handed to +``OpenCTIConnectorHelper`` via ``to_helper_config()``. +""" import importlib +from typing import Any +from unittest.mock import MagicMock +import pytest from connector import ConnectorSettings, QualysCVEEnrichmentConnector +from pycti import OpenCTIConnectorHelper + +CONFIG_DICT: dict[str, Any] = { + "opencti": { + "url": "http://localhost:8080", + "token": "test-token-00000000-0000-0000-0000-000000000000", + }, + "connector": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Qualys CVE Enrichment", + "scope": "vulnerability", + "log_level": "info", + "auto": False, + }, + "qualys_cve_enrichment": { + "base_url": "https://qualysapi.qualys.com", + "username": "test-username", + "password": "test-password", + "ssl_verify": True, + "api_version": "v2", + "max_tlp": "amber+strict", + }, +} + + +class StubConnectorSettings(ConnectorSettings): + """ConnectorSettings that reads a fixed dict instead of env/config vars.""" + + @classmethod + def _load_config_dict(cls, _, handler) -> Any: + return handler(CONFIG_DICT) + + +@pytest.fixture(name="mock_opencti_connector_helper") +def fixture_mock_opencti_connector_helper(monkeypatch): + """Neutralize pycti side effects (network, threads, schedulers).""" + 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 TestMainImports: @@ -18,3 +70,54 @@ def test_entrypoint_module_importable(self): # Importing main resolves the entrypoint wiring without executing the # ``__main__`` guard (no OpenCTI connection is attempted on import). assert importlib.import_module("main") is not None + + +class TestManagerSupportedWiring: + """Verify the settings -> helper -> connector wiring used by ``main.py``.""" + + def test_connector_settings_is_instantiated(self): + """Settings expose a pycti-compatible config dict.""" + settings = StubConnectorSettings() + + helper_config = settings.to_helper_config() + + assert isinstance(helper_config, dict) + assert helper_config["opencti"]["url"].rstrip("/") == "http://localhost:8080" + # pycti needs the real token, so it must not be masked. + assert ( + helper_config["opencti"]["token"] + == "test-token-00000000-0000-0000-0000-000000000000" + ) + assert helper_config["connector"]["type"] == "INTERNAL_ENRICHMENT" + # Connector-specific secrets are masked in the helper config dump; the + # connector reads them from the settings object instead. + assert helper_config["qualys_cve_enrichment"]["password"] == "**********" + + def test_opencti_connector_helper_is_instantiated( + self, mock_opencti_connector_helper + ): + """The helper is built from ``to_helper_config()``, as ``main.py`` does.""" + settings = StubConnectorSettings() + + helper = OpenCTIConnectorHelper( + config=settings.to_helper_config(), + playbook_compatible=True, + ) + + assert helper.connect_name == "Qualys CVE Enrichment" + assert helper.connect_scope == "vulnerability" + assert helper.connect_auto is False + + def test_connector_is_instantiated(self, mock_opencti_connector_helper): + """The connector accepts the settings object and the helper.""" + settings = StubConnectorSettings() + helper = OpenCTIConnectorHelper( + config=settings.to_helper_config(), + playbook_compatible=True, + ) + + connector = QualysCVEEnrichmentConnector(config=settings, helper=helper) + + assert connector.config is settings + assert connector.helper is helper + assert connector._max_tlp == "amber+strict"