From c1a942ad9432841bc4e027e67de1f5c3872cdd73 Mon Sep 17 00:00:00 2001 From: Yann Date: Thu, 29 Oct 2026 18:07:14 +0100 Subject: [PATCH] feat(expectation): add LOGGING expectation support for collectors (#8746) --- pyoaev/apis/collector.py | 4 + .../inject_expectation/inject_expectation.py | 176 +++++++++++++---- .../apis/inject_expectation/model/__init__.py | 8 +- .../inject_expectation/model/expectation.py | 14 ++ pyoaev/apis/inject_expectation_trace.py | 5 + pyoaev/contracts/contract_config.py | 10 +- pyoaev/daemons/collector_daemon.py | 74 ++++++++ pyoaev/signatures/models.py | 14 +- pyoaev/signatures/types.py | 1 + test/apis/expectation/test_expectation.py | 36 ++++ .../test_inject_expectation.py | 177 +++++++++++++++++- test/contracts/test_contract_expectations.py | 18 ++ test/daemons/test_collector_daemon.py | 150 +++++++++++++++ .../test_signature_manager_build_payload.py | 55 ++++++ 14 files changed, 696 insertions(+), 46 deletions(-) diff --git a/pyoaev/apis/collector.py b/pyoaev/apis/collector.py index 69ff43fb..7c02ff9d 100644 --- a/pyoaev/apis/collector.py +++ b/pyoaev/apis/collector.py @@ -25,6 +25,10 @@ class CollectorManager(GetMixin, ListMixin, CreateMixin, UpdateMixin, RESTManage # Source-declared author override for the collector's payloads and # contracts. When absent, the platform uses the collector name. "collector_author", + # Expectation types the collector can answer, e.g. + # ["DETECTION", "PREVENTION", "LOGGING"]. When absent, the platform + # assumes DETECTION and PREVENTION. + "collector_supported_expectation_types", ), ) diff --git a/pyoaev/apis/inject_expectation/inject_expectation.py b/pyoaev/apis/inject_expectation/inject_expectation.py index 082d0f69..b5428e75 100644 --- a/pyoaev/apis/inject_expectation/inject_expectation.py +++ b/pyoaev/apis/inject_expectation/inject_expectation.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, List +from typing import Any, Dict, List, Type, TypeVar from pydantic import ValidationError @@ -7,14 +7,55 @@ from pyoaev.apis.inject_expectation.model import ( DetectionExpectation, ExpectationTypeEnum, + LoggingExpectation, PreventionExpectation, ) +from pyoaev.apis.inject_expectation.model.expectation import Expectation from pyoaev.base import RESTManager, RESTObject from pyoaev.mixins import ListMixin, UpdateMixin from pyoaev.utils import RequiredOptional logger = logging.getLogger(__name__) +ExpectationModel = TypeVar("ExpectationModel", bound=Expectation) + +# Expectation models built by expectations_models_for_source, by expectation type. +# The /assets route only returns these two types. +_ASSET_EXPECTATION_MODELS: Dict[str, Type[Expectation]] = { + ExpectationTypeEnum.Detection.value: DetectionExpectation, + ExpectationTypeEnum.Prevention.value: PreventionExpectation, +} +_LOGGING_EXPECTATION_MODELS: Dict[str, Type[LoggingExpectation]] = { + ExpectationTypeEnum.Logging.value: LoggingExpectation, +} + + +def _as_list_of_dicts( + raw: Any, path: str, label: str, item_label: str +) -> List[Dict[str, Any]]: + """Checks that a server response is a JSON list of objects. + + :param label: what the list holds, in the plural (e.g. "AI expectations") + :param item_label: the same, in the singular (e.g. "AI expectation") + + :raises OpenAEVParsingError: if it is not + """ + if not isinstance(raw, list): + raise exc.OpenAEVParsingError( + error_message=( + f"Expected a list of {label} from {path}, got {type(raw).__name__}" + ) + ) + for item in raw: + if not isinstance(item, dict): + raise exc.OpenAEVParsingError( + error_message=( + f"Expected {item_label} objects (dicts) from {path}, " + f"got a list element of type {type(item).__name__}" + ) + ) + return [item for item in raw] + class InjectExpectation(RESTObject): _id_attr = "inject_expectation_id" @@ -24,13 +65,16 @@ class InjectExpectationManager(ListMixin, UpdateMixin, RESTManager): _path = "/injects/expectations" _obj_cls = InjectExpectation _update_attrs = RequiredOptional(required=("collector_id", "result", "is_success")) + # Whether the missing LOGGING route of an older platform was already reported. + _logging_route_missing_reported = False @exc.on_http_error(exc.OpenAEVUpdateError) def expectations_assets_for_source( self, source_id: str, expiration_time: int = None, **kwargs: Any - ) -> Dict[str, Any]: + ) -> List[Dict[str, Any]]: path = f"{self.path}/assets/" + source_id - result = self.openaev.http_get( + # The route returns a JSON list; http_get is annotated Union[Dict, Response]. + result: Any = self.openaev.http_get( path, query_data=( {"expiration_time": expiration_time} @@ -60,27 +104,71 @@ def ai_expectations_for_source( """ path = f"{self.path}/ai/{source_id}" # http_get is annotated Union[Dict, requests.Response]; widen to Any so the list - # validation below stays meaningful to type checkers, then build the result list - # explicitly to satisfy the declared return type. + # validation stays meaningful to type checkers. raw: Any = self.openaev.http_get(path, **kwargs) - if not isinstance(raw, list): - raise exc.OpenAEVParsingError( - error_message=( - f"Expected a list of AI expectations from {path}, " - f"got {type(raw).__name__}" - ) - ) - for item in raw: - if not isinstance(item, dict): - raise exc.OpenAEVParsingError( - error_message=( - f"Expected AI expectation objects (dicts) from {path}, " - f"got a list element of type {type(item).__name__}" - ) - ) - return [item for item in raw] + return _as_list_of_dicts(raw, path, "AI expectations", "AI expectation") + + @exc.on_http_error(exc.OpenAEVListError) + def logging_expectations_for_source( + self, source_id: str, **kwargs: Any + ) -> List[Dict[str, Any]]: + """Returns the LOGGING expectations not yet filled for the given source. + + Only collectors that declare LOGGING in their supported expectation + types receive them. An older platform without the LOGGING route answers + 404: it is treated as "no LOGGING expectation", with a warning. + + :param source_id: the identifier of the collector requesting expectations + :type source_id: str + :raises OpenAEVParsingError: if the server does not return a JSON list of dicts + :return: a list of logging expectation dicts + :rtype: list[dict] + """ + path = f"{self.path}/logging/{source_id}" + try: + raw: Any = self.openaev.http_get(path, **kwargs) + except exc.OpenAEVHttpError as err: + if err.response_code != 404: + raise + self._report_missing_logging_route(path) + return [] + return _as_list_of_dicts( + raw, path, "Logging expectations", "Logging expectation" + ) + + def _report_missing_logging_route(self, path: str) -> None: + if self._logging_route_missing_reported: + logger.debug("%s answered 404, no LOGGING expectation collected", path) + return + self._logging_route_missing_reported = True + logger.warning( + "%s answered 404: this OpenAEV platform does not support LOGGING " + "expectations yet, none will be collected", + path, + ) + + def logging_expectation_models_for_source( + self, source_id: str, **kwargs: Any + ) -> List[LoggingExpectation]: + """Returns the LOGGING expectations from OpenAEV that have had no result + yet from the source_id (e.g. collector), as models. + + :param source_id: the identifier of the collector requesting expectations + :type source_id: str + :param kwargs: additional data to pass to the endpoint + :type kwargs: dict, optional - def expectations_models_for_source(self, source_id: str, **kwargs: Any): + :return: a list of expectation objects + :rtype: list[LoggingExpectation] + """ + return self._models_from_dicts( + self.logging_expectations_for_source(source_id=source_id, **kwargs), + _LOGGING_EXPECTATION_MODELS, + ) + + def expectations_models_for_source( + self, source_id: str, **kwargs: Any + ) -> List[Expectation]: """Returns all expectations from OpenAEV that have had no result yet from the source_id (e.g. collector). @@ -92,20 +180,36 @@ def expectations_models_for_source(self, source_id: str, **kwargs: Any): :return: a list of expectation objects :rtype: list[DetectionExpectation|PreventionExpectation] """ - # TODO: we should implement a more clever mechanism to obtain - # specialised Expectation instances rather than just if/elseing - # through this list of possibilities. - expectations = [] - for expectation_dict in self.expectations_assets_for_source( - source_id=source_id, **kwargs - ): - if ( - expectation_dict["inject_expectation_type"] - == ExpectationTypeEnum.Detection.value - ): - expectation_class = DetectionExpectation - else: - expectation_class = PreventionExpectation + return self._models_from_dicts( + self.expectations_assets_for_source(source_id=source_id, **kwargs), + _ASSET_EXPECTATION_MODELS, + ) + + def _models_from_dicts( + self, + expectation_dicts: List[Dict[str, Any]], + model_by_type: Dict[str, Type[ExpectationModel]], + ) -> List[ExpectationModel]: + """Builds the model of each expectation dict, by expectation type. + + A type missing from model_by_type is skipped with a warning, never + mapped to another model: a collector must not answer an expectation + type it does not handle. + """ + expectations: List[ExpectationModel] = [] + for expectation_dict in expectation_dicts: + expectation_type: str = ( + expectation_dict.get("inject_expectation_type") or "" + ) + expectation_class = model_by_type.get(expectation_type) + if expectation_class is None: + logger.warning( + "Skipping expectation %s, its type %s is not one of %s", + expectation_dict.get("inject_expectation_id"), + expectation_type, + ", ".join(model_by_type), + ) + continue # One invalid expectation (e.g. an unknown signature type) must not # hide the valid ones from the collector. try: diff --git a/pyoaev/apis/inject_expectation/model/__init__.py b/pyoaev/apis/inject_expectation/model/__init__.py index 8cedb2d1..a9c1c1bd 100644 --- a/pyoaev/apis/inject_expectation/model/__init__.py +++ b/pyoaev/apis/inject_expectation/model/__init__.py @@ -1,7 +1,13 @@ from .expectation import ( DetectionExpectation, ExpectationTypeEnum, + LoggingExpectation, PreventionExpectation, ) -__all__ = ["DetectionExpectation", "ExpectationTypeEnum", "PreventionExpectation"] +__all__ = [ + "DetectionExpectation", + "ExpectationTypeEnum", + "LoggingExpectation", + "PreventionExpectation", +] diff --git a/pyoaev/apis/inject_expectation/model/expectation.py b/pyoaev/apis/inject_expectation/model/expectation.py index 3e64ce0d..bafa580b 100644 --- a/pyoaev/apis/inject_expectation/model/expectation.py +++ b/pyoaev/apis/inject_expectation/model/expectation.py @@ -14,6 +14,7 @@ class ExpectationTypeEnum(str, Enum): Detection = "DETECTION" Prevention = "PREVENTION" + Logging = "LOGGING" Vulnerability = "VULNERABILITY" Other = "other" @@ -181,3 +182,16 @@ class PreventionExpectation(Expectation): success_label: str = "Prevented" failure_label: str = "Not Prevented" + + +class LoggingExpectation(Expectation): + """An expectation that is specific to Logging, i.e. that is used + by OpenAEV to assert that an inject's execution was recorded by the + log sources of a security platform, whether it raised an alert or not. + + Collectors only report successes for this type: "Not Logged" is written + by the platform when the expectation expires. + """ + + success_label: str = "Logged" + failure_label: str = "Not Logged" diff --git a/pyoaev/apis/inject_expectation_trace.py b/pyoaev/apis/inject_expectation_trace.py index ac9e154d..69015eef 100644 --- a/pyoaev/apis/inject_expectation_trace.py +++ b/pyoaev/apis/inject_expectation_trace.py @@ -21,6 +21,11 @@ class InjectExpectationTraceManager(CreateMixin, RESTManager): "inject_expectation_trace_alert_link", "inject_expectation_trace_date", ), + optional=( + # Signal that matched, for a LOGGING expectation: "marker", + # "source_ip" or "target_ip". Left out for the other types. + "inject_expectation_trace_match_type", + ), ) @exc.on_http_error(exc.OpenAEVUpdateError) diff --git a/pyoaev/contracts/contract_config.py b/pyoaev/contracts/contract_config.py index da42b8b8..af75b432 100644 --- a/pyoaev/contracts/contract_config.py +++ b/pyoaev/contracts/contract_config.py @@ -123,6 +123,7 @@ class ExpectationType(str, Enum): manual: str = "MANUAL" prevention: str = "PREVENTION" detection: str = "DETECTION" + logging: str = "LOGGING" vulnerability: str = "VULNERABILITY" @@ -130,9 +131,9 @@ class SecurityPlatformType(str, Enum): """Categories of security platform expected to fulfil a technical expectation. When an expectation declares one or more of these, the platform focuses the - technical (DETECTION / PREVENTION / VULNERABILITY) result on collectors of - those types only (instead of every connected security platform). An empty - list means "any security platform". + technical (DETECTION / PREVENTION / LOGGING / VULNERABILITY) result on + collectors of those types only (instead of every connected security + platform). An empty list means "any security platform". """ EDR: str = "EDR" @@ -156,7 +157,8 @@ class Expectation: expectation_is_multi_selectable: bool = False # Security platform types expected to fulfil this expectation. Empty = any # platform (unchanged behaviour). Typically set for technical expectations - # (DETECTION / PREVENTION / VULNERABILITY), left empty for MANUAL ones. + # (DETECTION / PREVENTION / LOGGING / VULNERABILITY), left empty for MANUAL + # ones. expectation_expected_security_platform_types: List[SecurityPlatformType] = field( default_factory=list ) diff --git a/pyoaev/daemons/collector_daemon.py b/pyoaev/daemons/collector_daemon.py index 642f7441..c34289ee 100644 --- a/pyoaev/daemons/collector_daemon.py +++ b/pyoaev/daemons/collector_daemon.py @@ -1,5 +1,6 @@ import sched import time +from typing import Any from pyoaev import Configuration from pyoaev.daemons import BaseDaemon @@ -11,6 +12,35 @@ # consistent with the tag colors already used across collectors. DEFAULT_PLATFORM_TAG_COLOR = "#6b7280" +# Expectation types declared by a collector that does not choose its own. +DEFAULT_SUPPORTED_EXPECTATION_TYPES = ["DETECTION", "PREVENTION"] +# Expectation types a collector can declare. Listed explicitly: ExpectationTypeEnum +# maps any unknown value to Other, so it cannot be used to validate them. +DECLARABLE_EXPECTATION_TYPES = ("DETECTION", "PREVENTION", "LOGGING", "VULNERABILITY") + + +def _validate_expectation_types(expectation_types: list[str]) -> list[str]: + """Returns the expectation types without duplicates, in their order. + + :raises ValueError: if the list is empty or holds a value that is not an + uppercase declarable expectation type + """ + if isinstance(expectation_types, str) or not expectation_types: + raise ValueError( + "Supported expectation types must be a non-empty list, " + f"got {expectation_types!r}" + ) + validated = [] + for expectation_type in expectation_types: + if expectation_type not in DECLARABLE_EXPECTATION_TYPES: + raise ValueError( + f"Unsupported expectation type {expectation_type!r}, " + f"expected one of {', '.join(DECLARABLE_EXPECTATION_TYPES)}" + ) + if expectation_type not in validated: + validated.append(expectation_type) + return validated + class CollectorDaemon(BaseDaemon): """Implementation of a daemon of Collector type. Note that it requires @@ -28,6 +58,14 @@ class CollectorDaemon(BaseDaemon): 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. + + :param supported_expectation_types: expectation types the collector can + answer, sent to the platform at registration and on every ping, e.g. + ``["DETECTION", "PREVENTION", "LOGGING"]``. Defaults to DETECTION and + PREVENTION: a collector only receives LOGGING expectations once it + declares LOGGING. Use :meth:`update_supported_expectation_types` to + change it at runtime. + :type supported_expectation_types: list[str], optional """ def __init__( @@ -37,11 +75,19 @@ def __init__( logger=None, api_client=None, collector_type=None, + supported_expectation_types: list[str] | None = None, ): super().__init__(configuration, callback, logger, api_client) if collector_type is None: raise ValueError("Must define a value for collector type") self.collector_type = collector_type + self._supported_expectation_types = _validate_expectation_types( + DEFAULT_SUPPORTED_EXPECTATION_TYPES + if supported_expectation_types is None + else supported_expectation_types + ) + # Registration payload, re-sent by PingAlive; set by _setup. + self._registration_config: dict[str, Any] | None = None def _setup(self): if self._configuration.get("collector_period") is None: @@ -84,13 +130,41 @@ def _setup(self): # Optional author override; None lets the platform fall back to # the collector name. "collector_author": self._configuration.get("collector_author"), + "collector_supported_expectation_types": list( + self._supported_expectation_types + ), } with open(icon_path, "rb") as icon_file_handle: collector_icon = (icon_name, icon_file_handle, "image/png") self.api.collector.create(config, collector_icon) + self._registration_config = config PingAlive(self.api, config, self.logger, "collector").start() + def update_supported_expectation_types( + self, supported_expectation_types: list[str] + ) -> None: + """Changes the expectation types declared to the platform. + + Once the collector is registered, the next ping (within 40 seconds) + carries the new list. For example, a collector withdraws LOGGING when + its log source becomes unusable, so the platform stops waiting for its + LOGGING results. + + :param supported_expectation_types: the new list, e.g. ``["DETECTION", "PREVENTION"]`` + :type supported_expectation_types: list[str] + :raises ValueError: if the list is invalid; the declared list is then unchanged + """ + validated = _validate_expectation_types(supported_expectation_types) + self._supported_expectation_types = validated + if self._registration_config is not None: + # Assign a new list, never change it in place: the PingAlive thread + # may be serializing the current one. + self._registration_config["collector_supported_expectation_types"] = list( + validated + ) + self.logger.info(f"Supported expectation types: {', '.join(validated)}") + def __upsert_platform_tags(self, tag_names) -> list: """Upserts the configured security platform tags and returns their ids. diff --git a/pyoaev/signatures/models.py b/pyoaev/signatures/models.py index c8e914f9..b6115b8a 100644 --- a/pyoaev/signatures/models.py +++ b/pyoaev/signatures/models.py @@ -80,18 +80,26 @@ class ExtraSignatureData(BaseModel): detection: dict[str, JsonValue] = Field(default_factory=dict) prevention: dict[str, JsonValue] = Field(default_factory=dict) + logging: dict[str, JsonValue] = Field(default_factory=dict) vulnerability: dict[str, JsonValue] = Field(default_factory=dict) def get_extra(self, expectation_type: str) -> dict[str, JsonValue]: + """Returns the extra signatures of an expectation type, or ``{}`` + when it has none. + + Injectors pass every expectation type of the inject, LOGGING included, + so getting the extras never raises. Note that ``ExpectationSignatureGroup`` + still rejects a type outside ``ExpectationType``. + """ if expectation_type.lower() == "detection": return self.detection if expectation_type.lower() == "prevention": return self.prevention + if expectation_type.lower() == "logging": + return self.logging if expectation_type.lower() == "vulnerability": return self.vulnerability - raise ValueError( - f"Expectation type should be one of the available parameters: {list(self.model_fields.keys())}" - ) + return {} class SignatureTarget(BaseModel): diff --git a/pyoaev/signatures/types.py b/pyoaev/signatures/types.py index 5a5102ec..118150cb 100644 --- a/pyoaev/signatures/types.py +++ b/pyoaev/signatures/types.py @@ -4,6 +4,7 @@ class ExpectationType(str, Enum): DETECTION = "DETECTION" PREVENTION = "PREVENTION" + LOGGING = "LOGGING" VULNERABILITY = "VULNERABILITY" diff --git a/test/apis/expectation/test_expectation.py b/test/apis/expectation/test_expectation.py index 65016dd6..060834f0 100644 --- a/test/apis/expectation/test_expectation.py +++ b/test/apis/expectation/test_expectation.py @@ -3,6 +3,7 @@ from pyoaev.apis.inject_expectation.model import ( DetectionExpectation, + LoggingExpectation, PreventionExpectation, ) from pyoaev.signatures.signature_type import SignatureType @@ -14,6 +15,41 @@ def create_mock_api_client(): class TestExpectation(unittest.TestCase): + def test_when_logging_model_when_success_update_labels_correct(self): + inject_expectation_id = uuid4() + sender_id = uuid4() + api_client = create_mock_api_client() + model = LoggingExpectation( + **{ + "inject_expectation_id": inject_expectation_id, + "inject_expectation_signatures": [], + }, + api_client=api_client, + ) + + model.update(success=True, sender_id=sender_id, metadata={}) + + api_client.update.assert_called_once_with( + inject_expectation_id, + inject_expectation={ + "collector_id": sender_id, + "result": "Logged", + "is_success": True, + "metadata": {}, + }, + ) + + def test_when_logging_model_failure_label_is_not_logged(self): + model = LoggingExpectation( + **{ + "inject_expectation_id": uuid4(), + "inject_expectation_signatures": [], + }, + api_client=create_mock_api_client(), + ) + + self.assertEqual("Not Logged", model.failure_label) + def test_when_detection_model_when_success_update_labels_correct(self): inject_expectation_id = uuid4() sender_id = uuid4() diff --git a/test/apis/inject_expectation/test_inject_expectation.py b/test/apis/inject_expectation/test_inject_expectation.py index 0f0435b0..7df00af9 100644 --- a/test/apis/inject_expectation/test_inject_expectation.py +++ b/test/apis/inject_expectation/test_inject_expectation.py @@ -4,6 +4,7 @@ from pyoaev import OpenAEV from pyoaev.apis.inject_expectation.model import ( DetectionExpectation, + LoggingExpectation, PreventionExpectation, ) from pyoaev.exceptions import OpenAEVHttpError, OpenAEVListError, OpenAEVParsingError @@ -70,6 +71,82 @@ def test_http_error_is_mapped_to_list_error(self): api_client.inject_expectation.ai_expectations_for_source("collector-1") +class TestLoggingExpectationsForSource(TestCase): + @mock.patch("requests.Session.request") + def test_returns_list_of_expectations_from_the_logging_route(self, mock_request): + expectations = [ + {"inject_expectation_id": "exp-1"}, + {"inject_expectation_id": "exp-2"}, + ] + mock_request.return_value = make_json_response(expectations) + api_client = OpenAEV("url", "token") + + result = api_client.inject_expectation.logging_expectations_for_source( + "collector-1" + ) + + mock_request.assert_called_once() + _, kwargs = mock_request.call_args + self.assertEqual(kwargs["method"], "get") + self.assertEqual( + kwargs["url"], "url/api/injects/expectations/logging/collector-1" + ) + self.assertEqual(result, expectations) + + @mock.patch("requests.Session.request") + def test_raises_parsing_error_when_not_a_list(self, mock_request): + mock_request.return_value = make_json_response({"unexpected": "shape"}) + api_client = OpenAEV("url", "token") + + with self.assertRaises(OpenAEVParsingError): + api_client.inject_expectation.logging_expectations_for_source("collector-1") + + @mock.patch("requests.Session.request") + def test_raises_parsing_error_when_elements_not_dicts(self, mock_request): + mock_request.return_value = make_json_response(["not", "a", "dict"]) + api_client = OpenAEV("url", "token") + + with self.assertRaises(OpenAEVParsingError): + api_client.inject_expectation.logging_expectations_for_source("collector-1") + + def test_older_platform_404_returns_empty_list_and_warns_once(self): + api_client = OpenAEV("url", "token") + + with mock.patch.object( + OpenAEV, + "http_get", + side_effect=OpenAEVHttpError("Not Found", response_code=404), + ): + with self.assertLogs( + "pyoaev.apis.inject_expectation.inject_expectation", level="DEBUG" + ) as logs: + first = api_client.inject_expectation.logging_expectations_for_source( + "collector-1" + ) + second = api_client.inject_expectation.logging_expectations_for_source( + "collector-1" + ) + + self.assertEqual([], first) + self.assertEqual([], second) + warnings = [r for r in logs.records if r.levelname == "WARNING"] + self.assertEqual(1, len(warnings)) + self.assertIn("/logging/collector-1", warnings[0].getMessage()) + + def test_other_http_errors_are_mapped_to_list_error(self): + api_client = OpenAEV("url", "token") + + with mock.patch.object( + OpenAEV, + "http_get", + side_effect=OpenAEVHttpError("boom", response_code=500), + ): + with self.assertRaises(OpenAEVListError): + api_client.inject_expectation.logging_expectations_for_source( + "collector-1" + ) + + def make_expectation(expectation_type, signatures): return { "inject_expectation_id": str(uuid4()), @@ -99,15 +176,33 @@ def test_builds_the_model_matching_each_expectation_type(self): [ make_expectation("DETECTION", signatures), make_expectation("PREVENTION", signatures), - make_expectation("VULNERABILITY", signatures), ] ) self.assertEqual( - [DetectionExpectation, PreventionExpectation, PreventionExpectation], + [DetectionExpectation, PreventionExpectation], [type(model) for model in models], ) + def test_other_expectation_types_are_skipped_not_built_as_prevention(self): + signatures = [{"type": "process_name", "value": "obas-implant.exe"}] + detection = make_expectation("DETECTION", signatures) + unknown = [ + make_expectation(expectation_type, signatures) + for expectation_type in ("VULNERABILITY", "LOGGING", "FOO") + ] + + with self.assertLogs( + "pyoaev.apis.inject_expectation.inject_expectation", level="WARNING" + ) as logs: + models = self.models_for([detection, *unknown]) + + self.assertEqual([DetectionExpectation], [type(model) for model in models]) + self.assertEqual(3, len(logs.records)) + for expectation, output in zip(unknown, logs.output): + self.assertIn(expectation["inject_expectation_id"], output) + self.assertIn(expectation["inject_expectation_type"], output) + def test_an_invalid_expectation_does_not_hide_the_valid_ones(self): # Regression for client-python#352: signature types sent by SignatureManager # before the fix (start_time, source_ipv4, ...) are not SignatureTypes. @@ -162,5 +257,83 @@ def test_expectations_with_null_signatures_are_still_returned(self): self.assertEqual([], models[0].inject_expectation_signatures) +class TestLoggingExpectationModelsForSource(TestCase): + def setUp(self): + self.api_client = OpenAEV("url", "token") + + def models_for(self, expectations): + with mock.patch.object( + self.api_client.inject_expectation, + "logging_expectations_for_source", + return_value=expectations, + ): + return self.api_client.inject_expectation.logging_expectation_models_for_source( + "collector-1" + ) + + def test_builds_logging_expectations_with_logging_labels(self): + models = self.models_for( + [ + make_expectation( + "LOGGING", + [{"type": "parent_process_name", "value": "oaev-implant-1"}], + ) + ] + ) + + self.assertEqual([LoggingExpectation], [type(model) for model in models]) + self.assertEqual("Logged", models[0].success_label) + self.assertEqual("Not Logged", models[0].failure_label) + + def test_other_expectation_types_are_skipped(self): + logging_expectation = make_expectation("LOGGING", []) + detection = make_expectation("DETECTION", []) + + with self.assertLogs( + "pyoaev.apis.inject_expectation.inject_expectation", level="WARNING" + ) as logs: + models = self.models_for([detection, logging_expectation]) + + self.assertEqual( + [logging_expectation["inject_expectation_id"]], + [str(model.inject_expectation_id) for model in models], + ) + self.assertEqual(1, len(logs.records)) + self.assertIn(detection["inject_expectation_id"], logs.output[0]) + + def test_an_invalid_expectation_is_skipped_with_a_warning(self): + valid = make_expectation( + "LOGGING", [{"type": "source_ipv4_address", "value": "10.0.0.5"}] + ) + invalid = make_expectation("LOGGING", [{"type": "start_time", "value": "x"}]) + + with self.assertLogs( + "pyoaev.apis.inject_expectation.inject_expectation", level="WARNING" + ) as logs: + models = self.models_for([invalid, valid]) + + self.assertEqual( + [valid["inject_expectation_id"]], + [str(model.inject_expectation_id) for model in models], + ) + self.assertEqual(1, len(logs.records)) + self.assertIn("LoggingExpectation", logs.output[0]) + + def test_older_platform_returns_no_model(self): + with mock.patch.object( + OpenAEV, + "http_get", + side_effect=OpenAEVHttpError("Not Found", response_code=404), + ): + with self.assertLogs( + "pyoaev.apis.inject_expectation.inject_expectation", level="WARNING" + ): + models = self.api_client.inject_expectation.logging_expectation_models_for_source( + "collector-1" + ) + + self.assertEqual([], models) + + if __name__ == "__main__": main() diff --git a/test/contracts/test_contract_expectations.py b/test/contracts/test_contract_expectations.py index 76185755..c24cbcd5 100644 --- a/test/contracts/test_contract_expectations.py +++ b/test/contracts/test_contract_expectations.py @@ -164,6 +164,24 @@ def test_expected_security_platform_types_serialize(self): ], ) + def test_logging_expectation_serializes_as_logging(self): + field = ContractExpectations( + key="expectations", + label="Expectations", + availableExpectations=[ + _expectation(ExpectationType.logging, "Logging", True), + ], + ) + + data = _serialize(field) + + self.assertEqual( + "LOGGING", data["availableExpectations"][0]["expectation_type"] + ) + self.assertEqual( + "LOGGING", data["predefinedExpectations"][0]["expectation_type"] + ) + if __name__ == "__main__": unittest.main() diff --git a/test/daemons/test_collector_daemon.py b/test/daemons/test_collector_daemon.py index 69ee0552..621d5ef1 100644 --- a/test/daemons/test_collector_daemon.py +++ b/test/daemons/test_collector_daemon.py @@ -6,6 +6,7 @@ from pyoaev.daemons.collector_daemon import ( DEFAULT_PERIOD_SECONDS, DEFAULT_PLATFORM_TAG_COLOR, + DEFAULT_SUPPORTED_EXPECTATION_TYPES, ) @@ -162,5 +163,154 @@ def test_security_platform_upsert_without_description_and_tags_is_unchanged( mock_tag_upsert.assert_not_called() +def _collector_config(): + return Configuration( + config_hints={ + "openaev_url": {"data": "fake"}, + "openaev_token": {"data": "fake"}, + "collector_id": {"data": "fake id"}, + } + ) + + +@patch("pyoaev.apis.DocumentManager.upsert", return_value={}) +@patch("pyoaev.apis.CollectorManager.create", return_value={}) +@patch("builtins.open", new_callable=mock_open, read_data="data") +@patch("pyoaev.daemons.collector_daemon.PingAlive") +class TestCollectorDaemonSupportedExpectationTypes(unittest.TestCase): + @staticmethod + def _registered_types(mock_collector_create): + return mock_collector_create.call_args.args[0][ + "collector_supported_expectation_types" + ] + + @staticmethod + def _ping_config(mock_ping_alive): + return mock_ping_alive.call_args.args[1] + + def test_detection_and_prevention_are_declared_by_default( + self, mock_ping_alive, _open, mock_collector_create, _upsert + ): + collector = CollectorDaemon( + configuration=_collector_config(), collector_type="test" + ) + + collector._setup() + + self.assertEqual( + DEFAULT_SUPPORTED_EXPECTATION_TYPES, + self._registered_types(mock_collector_create), + ) + + def test_declared_types_are_registered_without_duplicates( + self, mock_ping_alive, _open, mock_collector_create, _upsert + ): + collector = CollectorDaemon( + configuration=_collector_config(), + collector_type="test", + supported_expectation_types=["DETECTION", "LOGGING", "DETECTION"], + ) + + collector._setup() + + self.assertEqual( + ["DETECTION", "LOGGING"], self._registered_types(mock_collector_create) + ) + + def test_ping_alive_re_sends_the_registration_payload( + self, mock_ping_alive, _open, mock_collector_create, _upsert + ): + collector = CollectorDaemon( + configuration=_collector_config(), + collector_type="test", + supported_expectation_types=["DETECTION", "PREVENTION", "LOGGING"], + ) + + collector._setup() + + self.assertIs( + mock_collector_create.call_args.args[0], + self._ping_config(mock_ping_alive), + ) + mock_ping_alive.return_value.start.assert_called_once() + + def test_invalid_types_are_rejected_at_construction( + self, mock_ping_alive, _open, mock_collector_create, _upsert + ): + # "other" is what ExpectationTypeEnum maps unknown values to; types are + # uppercase and case-sensitive. + for invalid in ( + ["FOO"], + ["other"], + ["logging"], + ["DETECTION", None], + [], + "LOGGING", + ): + with self.subTest(invalid=invalid): + with self.assertRaises(ValueError): + CollectorDaemon( + configuration=_collector_config(), + collector_type="test", + supported_expectation_types=invalid, + ) + + def test_update_after_setup_changes_the_list_sent_by_the_next_ping( + self, mock_ping_alive, _open, mock_collector_create, _upsert + ): + collector = CollectorDaemon( + configuration=_collector_config(), + collector_type="test", + supported_expectation_types=["DETECTION", "PREVENTION", "LOGGING"], + ) + collector._setup() + ping_config = self._ping_config(mock_ping_alive) + registered = ping_config["collector_supported_expectation_types"] + + collector.update_supported_expectation_types(["DETECTION", "PREVENTION"]) + + self.assertEqual( + ["DETECTION", "PREVENTION"], + ping_config["collector_supported_expectation_types"], + ) + # A new list: the one the ping thread may be serializing is left as is. + self.assertEqual(["DETECTION", "PREVENTION", "LOGGING"], registered) + + def test_invalid_update_raises_and_keeps_the_declared_list( + self, mock_ping_alive, _open, mock_collector_create, _upsert + ): + collector = CollectorDaemon( + configuration=_collector_config(), + collector_type="test", + supported_expectation_types=["DETECTION", "LOGGING"], + ) + collector._setup() + + with self.assertRaises(ValueError): + collector.update_supported_expectation_types(["DETECTION", "FOO"]) + + self.assertEqual( + ["DETECTION", "LOGGING"], + self._ping_config(mock_ping_alive)["collector_supported_expectation_types"], + ) + + def test_update_before_setup_is_the_registered_list( + self, mock_ping_alive, _open, mock_collector_create, _upsert + ): + collector = CollectorDaemon( + configuration=_collector_config(), collector_type="test" + ) + + collector.update_supported_expectation_types( + ["DETECTION", "PREVENTION", "LOGGING"] + ) + collector._setup() + + self.assertEqual( + ["DETECTION", "PREVENTION", "LOGGING"], + self._registered_types(mock_collector_create), + ) + + if __name__ == "__main__": unittest.main() diff --git a/test/signatures/test_signature_manager_build_payload.py b/test/signatures/test_signature_manager_build_payload.py index 2a5587f1..a6ecc749 100644 --- a/test/signatures/test_signature_manager_build_payload.py +++ b/test/signatures/test_signature_manager_build_payload.py @@ -230,6 +230,61 @@ def test_nothing_is_logged_when_every_signature_is_sent(self): ) +class TestBuildPayloadLoggingExpectation(unittest.TestCase): + """Injectors pass every expectation type of the inject: LOGGING must get its + own group and never make the Detection and Prevention signatures fail.""" + + def test_logging_gets_a_group_with_the_same_base_signatures(self): + payload = SignatureManager.build_payload( + _network_signature(target_ipv4="192.0.2.11"), + [TARGET], + ["DETECTION", "PREVENTION", "LOGGING"], + ) + + groups = payload["targets"][0]["signature_values"] + self.assertEqual( + ["DETECTION", "PREVENTION", "LOGGING"], + [group["expectation_type"] for group in groups], + ) + self.assertEqual( + _values(payload, group_index=0), _values(payload, group_index=2) + ) + self.assertEqual( + _values(payload, group_index=1), _values(payload, group_index=2) + ) + + def test_logging_extras_only_apply_to_the_logging_group(self): + extra = ExtraSignatureData(logging={"process_name": "nmap"}) + + payload = SignatureManager.build_payload( + _network_signature(target_ipv4="192.0.2.11"), + [TARGET], + ["DETECTION", "LOGGING"], + extra, + ) + + self.assertNotIn("process_name", _values(payload, group_index=0)) + self.assertEqual("nmap", _values(payload, group_index=1)["process_name"]) + + +class TestExtraSignatureDataGetExtra(unittest.TestCase): + def test_logging_without_extras_is_empty(self): + extra = ExtraSignatureData(detection={"process_name": "nmap"}) + + self.assertEqual({}, extra.get_extra("LOGGING")) + + def test_unknown_type_is_empty_instead_of_raising(self): + extra = ExtraSignatureData(detection={"process_name": "nmap"}) + + self.assertEqual({}, extra.get_extra("FOO")) + + def test_known_types_are_case_insensitive(self): + extra = ExtraSignatureData(logging={"process_name": "nmap"}) + + self.assertEqual({"process_name": "nmap"}, extra.get_extra("logging")) + self.assertEqual({"process_name": "nmap"}, extra.get_extra("LOGGING")) + + class TestBuildPayloadIsParsedByCollectors(unittest.TestCase): """Regression for client-python#352: a collector must be able to parse the expectation signatures the platform stores from this payload."""