diff --git a/pyoaev/configuration/settings_loader.py b/pyoaev/configuration/settings_loader.py index 83cd86ab..908dfbfd 100644 --- a/pyoaev/configuration/settings_loader.py +++ b/pyoaev/configuration/settings_loader.py @@ -134,3 +134,14 @@ class ConfigLoaderCollector(BaseConfigModel): description="Optional author override for the connector's payloads and contracts. " "When absent, the platform attributes them to the connector's name.", ) + platform_description: str | None = Field( + default=None, + description="Description applied to the security platform auto-created by the " + "collector, so its read-only card is not empty in the platform UI.", + ) + platform_tags: list[str] | str | None = Field( + default=None, + description="Tag names applied to the security platform auto-created by the " + "collector. Accepts a list of names or a single comma-separated string " + "(the usual shape when configured through an environment variable).", + ) diff --git a/pyoaev/daemons/collector_daemon.py b/pyoaev/daemons/collector_daemon.py index 00d156a3..642f7441 100644 --- a/pyoaev/daemons/collector_daemon.py +++ b/pyoaev/daemons/collector_daemon.py @@ -7,6 +7,10 @@ DEFAULT_PERIOD_SECONDS = 60 +# Neutral grey used for tags auto-created alongside the security platform, +# consistent with the tag colors already used across collectors. +DEFAULT_PLATFORM_TAG_COLOR = "#6b7280" + class CollectorDaemon(BaseDaemon): """Implementation of a daemon of Collector type. Note that it requires @@ -19,6 +23,11 @@ class CollectorDaemon(BaseDaemon): `collector_author` (optional): source-declared author override for the collector's payloads and contracts; when absent, the platform attributes them to the collector's name. + `collector_platform_description` (optional): description applied to the + security platform auto-created when `collector_platform` is set, so the + read-only card is not empty in the UI. + `collector_platform_tags` (optional): list of tag names (or a + comma-separated string) applied to the auto-created security platform. """ def __init__( @@ -43,18 +52,25 @@ def _setup(self): collector_icon = (icon_name, icon_file_handle, "image/png") document = self.api.document.upsert(document={}, file=collector_icon) if self._configuration.get("collector_platform") is not None: + security_platform_input = { + "asset_name": self._configuration.get("collector_name"), + "asset_external_reference": self._configuration.get("collector_id"), + "security_platform_type": self._configuration.get( + "collector_platform" + ), + "security_platform_logo_light": document.get("document_id"), + "security_platform_logo_dark": document.get("document_id"), + } + description = self._configuration.get("collector_platform_description") + if description: + security_platform_input["asset_description"] = description + tag_ids = self.__upsert_platform_tags( + self._configuration.get("collector_platform_tags") + ) + if tag_ids: + security_platform_input["asset_tags"] = tag_ids security_platform = self.api.security_platform.upsert( - { - "asset_name": self._configuration.get("collector_name"), - "asset_external_reference": self._configuration.get( - "collector_id" - ), - "security_platform_type": self._configuration.get( - "collector_platform" - ), - "security_platform_logo_light": document.get("document_id"), - "security_platform_logo_dark": document.get("document_id"), - } + security_platform_input ) else: security_platform = {} @@ -75,6 +91,37 @@ def _setup(self): PingAlive(self.api, config, self.logger, "collector").start() + def __upsert_platform_tags(self, tag_names) -> list: + """Upserts the configured security platform tags and returns their ids. + + Accepts either a list of tag names or a comma-separated string; tag + upsert failures are logged and skipped so the collector setup never + fails because of a tag. + """ + if not tag_names: + return [] + if isinstance(tag_names, str): + tag_names = tag_names.split(",") + tag_ids = [] + for raw_name in tag_names: + # Strip whitespace for both string and list inputs (YAML list values + # can carry stray spaces too). + tag_name = raw_name.strip() if isinstance(raw_name, str) else raw_name + if not tag_name: + continue + try: + tag = self.api.tag.upsert( + {"tag_name": tag_name, "tag_color": DEFAULT_PLATFORM_TAG_COLOR} + ) + tag_id = tag.get("tag_id") + if tag_id: + tag_ids.append(tag_id) + except Exception as err: # noqa: BLE001 + self.logger.warning( + f"Could not upsert security platform tag {tag_name}: {err}" + ) + return tag_ids + def _start_loop(self): scheduler = sched.scheduler(time.time, time.sleep) delay = self._configuration.get("collector_period") diff --git a/test/daemons/test_collector_daemon.py b/test/daemons/test_collector_daemon.py index a5f5a2b9..69ee0552 100644 --- a/test/daemons/test_collector_daemon.py +++ b/test/daemons/test_collector_daemon.py @@ -3,7 +3,10 @@ from pyoaev.configuration import Configuration from pyoaev.daemons import CollectorDaemon -from pyoaev.daemons.collector_daemon import DEFAULT_PERIOD_SECONDS +from pyoaev.daemons.collector_daemon import ( + DEFAULT_PERIOD_SECONDS, + DEFAULT_PLATFORM_TAG_COLOR, +) class TestCollectorDaemon(unittest.TestCase): @@ -34,6 +37,130 @@ def test_when_no_period_config_provided_set_default_period( self.assertEqual(config.get("collector_period"), DEFAULT_PERIOD_SECONDS) + @patch("pyoaev.apis.SecurityPlatformManager.upsert") + @patch("pyoaev.apis.TagManager.upsert") + @patch("pyoaev.apis.DocumentManager.upsert") + @patch("pyoaev.apis.CollectorManager.create") + @patch("builtins.open", new_callable=mock_open, read_data="data") + @patch("pyoaev.utils.PingAlive.start") + def test_security_platform_upsert_carries_description_and_tags( + self, + mock_ping_alive, + mock_open_local, + mock_collector_create, + mock_document_upsert, + mock_tag_upsert, + mock_security_platform_upsert, + ): + mock_ping_alive.return_value = None + mock_collector_create.return_value = {} + mock_document_upsert.return_value = {"document_id": "doc id"} + mock_tag_upsert.side_effect = [{"tag_id": "tag-1"}, {"tag_id": "tag-2"}] + mock_security_platform_upsert.return_value = {"asset_id": "sp id"} + config = Configuration( + config_hints={ + "openaev_url": {"data": "fake"}, + "openaev_token": {"data": "fake"}, + "collector_id": {"data": "fake id"}, + "collector_name": {"data": "Fake EDR"}, + "collector_platform": {"data": "EDR"}, + "collector_platform_description": {"data": "A fake EDR platform."}, + # Padded name: list values must be stripped like string ones. + "collector_platform_tags": {"data": ["edr", " fake-vendor "]}, + } + ) + collector = CollectorDaemon(configuration=config, collector_type="test") + + collector._setup() + + payload = mock_security_platform_upsert.call_args.args[0] + self.assertEqual(payload["asset_description"], "A fake EDR platform.") + self.assertEqual(payload["asset_tags"], ["tag-1", "tag-2"]) + self.assertEqual(mock_tag_upsert.call_count, 2) + self.assertEqual(mock_tag_upsert.call_args_list[0].args[0]["tag_name"], "edr") + self.assertEqual( + mock_tag_upsert.call_args_list[1].args[0]["tag_name"], "fake-vendor" + ) + self.assertEqual( + mock_tag_upsert.call_args_list[0].args[0]["tag_color"], + DEFAULT_PLATFORM_TAG_COLOR, + ) + + @patch("pyoaev.apis.SecurityPlatformManager.upsert") + @patch("pyoaev.apis.TagManager.upsert") + @patch("pyoaev.apis.DocumentManager.upsert") + @patch("pyoaev.apis.CollectorManager.create") + @patch("builtins.open", new_callable=mock_open, read_data="data") + @patch("pyoaev.utils.PingAlive.start") + def test_security_platform_tags_accept_comma_separated_string( + self, + mock_ping_alive, + mock_open_local, + mock_collector_create, + mock_document_upsert, + mock_tag_upsert, + mock_security_platform_upsert, + ): + mock_ping_alive.return_value = None + mock_collector_create.return_value = {} + mock_document_upsert.return_value = {"document_id": "doc id"} + mock_tag_upsert.side_effect = [{"tag_id": "tag-1"}, {"tag_id": "tag-2"}] + mock_security_platform_upsert.return_value = {"asset_id": "sp id"} + config = Configuration( + config_hints={ + "openaev_url": {"data": "fake"}, + "openaev_token": {"data": "fake"}, + "collector_id": {"data": "fake id"}, + "collector_name": {"data": "Fake SIEM"}, + "collector_platform": {"data": "SIEM"}, + "collector_platform_tags": {"data": "siem, fake-vendor"}, + } + ) + collector = CollectorDaemon(configuration=config, collector_type="test") + + collector._setup() + + payload = mock_security_platform_upsert.call_args.args[0] + self.assertNotIn("asset_description", payload) + self.assertEqual(payload["asset_tags"], ["tag-1", "tag-2"]) + + @patch("pyoaev.apis.SecurityPlatformManager.upsert") + @patch("pyoaev.apis.TagManager.upsert") + @patch("pyoaev.apis.DocumentManager.upsert") + @patch("pyoaev.apis.CollectorManager.create") + @patch("builtins.open", new_callable=mock_open, read_data="data") + @patch("pyoaev.utils.PingAlive.start") + def test_security_platform_upsert_without_description_and_tags_is_unchanged( + self, + mock_ping_alive, + mock_open_local, + mock_collector_create, + mock_document_upsert, + mock_tag_upsert, + mock_security_platform_upsert, + ): + mock_ping_alive.return_value = None + mock_collector_create.return_value = {} + mock_document_upsert.return_value = {"document_id": "doc id"} + mock_security_platform_upsert.return_value = {"asset_id": "sp id"} + config = Configuration( + config_hints={ + "openaev_url": {"data": "fake"}, + "openaev_token": {"data": "fake"}, + "collector_id": {"data": "fake id"}, + "collector_name": {"data": "Fake XDR"}, + "collector_platform": {"data": "XDR"}, + } + ) + collector = CollectorDaemon(configuration=config, collector_type="test") + + collector._setup() + + payload = mock_security_platform_upsert.call_args.args[0] + self.assertNotIn("asset_description", payload) + self.assertNotIn("asset_tags", payload) + mock_tag_upsert.assert_not_called() + if __name__ == "__main__": unittest.main()