From c022b1fd2d22fc18d431734580a16ef001e54b20 Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Sun, 26 Jul 2026 00:56:17 +0200 Subject: [PATCH 1/2] feat(daemons): support description and tags on the collector security platform (#319) --- pyoaev/configuration/settings_loader.py | 10 ++ pyoaev/daemons/collector_daemon.py | 66 +++++++++++--- test/daemons/test_collector_daemon.py | 116 ++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 11 deletions(-) diff --git a/pyoaev/configuration/settings_loader.py b/pyoaev/configuration/settings_loader.py index 83cd86ab..08fa2a39 100644 --- a/pyoaev/configuration/settings_loader.py +++ b/pyoaev/configuration/settings_loader.py @@ -134,3 +134,13 @@ 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] | None = Field( + default=None, + description="Tag names applied to the security platform auto-created by the " + "collector.", + ) diff --git a/pyoaev/daemons/collector_daemon.py b/pyoaev/daemons/collector_daemon.py index 00d156a3..b087f910 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,34 @@ 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 = [name.strip() for name in tag_names.split(",")] + tag_ids = [] + for tag_name in tag_names: + 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..a8ec891b 100644 --- a/test/daemons/test_collector_daemon.py +++ b/test/daemons/test_collector_daemon.py @@ -34,6 +34,122 @@ 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."}, + "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") + + @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() From 5e2ac5ebb7eb8e1c95956da860499ccbf1061b55 Mon Sep 17 00:00:00 2001 From: Samuel Hassine Date: Sun, 26 Jul 2026 03:23:01 +0200 Subject: [PATCH 2/2] fix(daemons): accept string platform_tags in config model and strip list values (#319) - ConfigLoaderCollector.platform_tags now accepts a comma-separated string as well as a list, matching the daemon's runtime behavior (env vars provide strings). - Tag names from list inputs are stripped like string ones. - Tests assert the neutral grey tag color and the stripped list value. --- pyoaev/configuration/settings_loader.py | 5 +++-- pyoaev/daemons/collector_daemon.py | 7 +++++-- test/daemons/test_collector_daemon.py | 15 +++++++++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/pyoaev/configuration/settings_loader.py b/pyoaev/configuration/settings_loader.py index 08fa2a39..908dfbfd 100644 --- a/pyoaev/configuration/settings_loader.py +++ b/pyoaev/configuration/settings_loader.py @@ -139,8 +139,9 @@ class ConfigLoaderCollector(BaseConfigModel): 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] | None = Field( + platform_tags: list[str] | str | None = Field( default=None, description="Tag names applied to the security platform auto-created by the " - "collector.", + "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 b087f910..642f7441 100644 --- a/pyoaev/daemons/collector_daemon.py +++ b/pyoaev/daemons/collector_daemon.py @@ -101,9 +101,12 @@ def __upsert_platform_tags(self, tag_names) -> list: if not tag_names: return [] if isinstance(tag_names, str): - tag_names = [name.strip() for name in tag_names.split(",")] + tag_names = tag_names.split(",") tag_ids = [] - for tag_name in tag_names: + 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: diff --git a/test/daemons/test_collector_daemon.py b/test/daemons/test_collector_daemon.py index a8ec891b..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): @@ -62,7 +65,8 @@ def test_security_platform_upsert_carries_description_and_tags( "collector_name": {"data": "Fake EDR"}, "collector_platform": {"data": "EDR"}, "collector_platform_description": {"data": "A fake EDR platform."}, - "collector_platform_tags": {"data": ["edr", "fake-vendor"]}, + # Padded name: list values must be stripped like string ones. + "collector_platform_tags": {"data": ["edr", " fake-vendor "]}, } ) collector = CollectorDaemon(configuration=config, collector_type="test") @@ -74,6 +78,13 @@ def test_security_platform_upsert_carries_description_and_tags( 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")