Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions pyoaev/configuration/settings_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
)
69 changes: 58 additions & 11 deletions pyoaev/daemons/collector_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__(
Expand All @@ -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 = {}
Expand All @@ -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")
Expand Down
129 changes: 128 additions & 1 deletion test/daemons/test_collector_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Comment on lines +40 to +44
@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()
Loading