Skip to content

Commit 6873435

Browse files
feat(expectation): add LOGGING expectation support for collectors (#8746) (#374)
Merging, CI error regarding PR not linked to an issue is because the issue is in another repo.
1 parent b37c03f commit 6873435

14 files changed

Lines changed: 751 additions & 47 deletions

File tree

‎pyoaev/apis/collector.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class CollectorManager(GetMixin, ListMixin, CreateMixin, UpdateMixin, RESTManage
2525
# Source-declared author override for the collector's payloads and
2626
# contracts. When absent, the platform uses the collector name.
2727
"collector_author",
28+
# Expectation types the collector can answer, e.g.
29+
# ["DETECTION", "PREVENTION", "LOGGING"]. When absent, the platform
30+
# assumes DETECTION and PREVENTION.
31+
"collector_supported_expectation_types",
2832
),
2933
)
3034

‎pyoaev/apis/inject_expectation/inject_expectation.py‎

Lines changed: 146 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,61 @@
11
import logging
2-
from typing import Any, Dict, List
2+
from typing import Any, Dict, List, Type, TypeVar
33

44
from pydantic import ValidationError
55

66
from pyoaev import exceptions as exc
77
from pyoaev.apis.inject_expectation.model import (
88
DetectionExpectation,
99
ExpectationTypeEnum,
10+
LoggingExpectation,
1011
PreventionExpectation,
1112
)
13+
from pyoaev.apis.inject_expectation.model.expectation import Expectation
1214
from pyoaev.base import RESTManager, RESTObject
1315
from pyoaev.mixins import ListMixin, UpdateMixin
1416
from pyoaev.utils import RequiredOptional
1517

1618
logger = logging.getLogger(__name__)
1719

20+
ExpectationModel = TypeVar("ExpectationModel", bound=Expectation)
21+
22+
# Expectation models built by expectations_models_for_source, by expectation type.
23+
# The /assets route only returns these two types.
24+
_ASSET_EXPECTATION_MODELS: Dict[str, Type[Expectation]] = {
25+
ExpectationTypeEnum.Detection.value: DetectionExpectation,
26+
ExpectationTypeEnum.Prevention.value: PreventionExpectation,
27+
}
28+
_LOGGING_EXPECTATION_MODELS: Dict[str, Type[LoggingExpectation]] = {
29+
ExpectationTypeEnum.Logging.value: LoggingExpectation,
30+
}
31+
32+
33+
def _as_list_of_dicts(
34+
raw: Any, path: str, label: str, item_label: str
35+
) -> List[Dict[str, Any]]:
36+
"""Checks that a server response is a JSON list of objects.
37+
38+
:param label: what the list holds, in the plural (e.g. "AI expectations")
39+
:param item_label: the same, in the singular (e.g. "AI expectation")
40+
41+
:raises OpenAEVParsingError: if it is not
42+
"""
43+
if not isinstance(raw, list):
44+
raise exc.OpenAEVParsingError(
45+
error_message=(
46+
f"Expected a list of {label} from {path}, got {type(raw).__name__}"
47+
)
48+
)
49+
for item in raw:
50+
if not isinstance(item, dict):
51+
raise exc.OpenAEVParsingError(
52+
error_message=(
53+
f"Expected {item_label} objects (dicts) from {path}, "
54+
f"got a list element of type {type(item).__name__}"
55+
)
56+
)
57+
return [item for item in raw]
58+
1859

1960
class InjectExpectation(RESTObject):
2061
_id_attr = "inject_expectation_id"
@@ -24,13 +65,21 @@ class InjectExpectationManager(ListMixin, UpdateMixin, RESTManager):
2465
_path = "/injects/expectations"
2566
_obj_cls = InjectExpectation
2667
_update_attrs = RequiredOptional(required=("collector_id", "result", "is_success"))
68+
# Whether the missing LOGGING route of an older platform was already reported.
69+
_logging_route_missing_reported = False
2770

2871
@exc.on_http_error(exc.OpenAEVUpdateError)
2972
def expectations_assets_for_source(
3073
self, source_id: str, expiration_time: int = None, **kwargs: Any
31-
) -> Dict[str, Any]:
74+
) -> List[Dict[str, Any]]:
75+
"""Returns the DETECTION and PREVENTION expectations on assets not yet
76+
filled for the given source.
77+
78+
:raises OpenAEVParsingError: if the server does not return a JSON list of dicts
79+
"""
3280
path = f"{self.path}/assets/" + source_id
33-
result = self.openaev.http_get(
81+
# The route returns a JSON list; http_get is annotated Union[Dict, Response].
82+
raw: Any = self.openaev.http_get(
3483
path,
3584
query_data=(
3685
{"expiration_time": expiration_time}
@@ -43,7 +92,7 @@ def expectations_assets_for_source(
4392
),
4493
**kwargs,
4594
)
46-
return result
95+
return _as_list_of_dicts(raw, path, "asset expectations", "asset expectation")
4796

4897
@exc.on_http_error(exc.OpenAEVListError)
4998
def ai_expectations_for_source(
@@ -60,27 +109,71 @@ def ai_expectations_for_source(
60109
"""
61110
path = f"{self.path}/ai/{source_id}"
62111
# http_get is annotated Union[Dict, requests.Response]; widen to Any so the list
63-
# validation below stays meaningful to type checkers, then build the result list
64-
# explicitly to satisfy the declared return type.
112+
# validation stays meaningful to type checkers.
65113
raw: Any = self.openaev.http_get(path, **kwargs)
66-
if not isinstance(raw, list):
67-
raise exc.OpenAEVParsingError(
68-
error_message=(
69-
f"Expected a list of AI expectations from {path}, "
70-
f"got {type(raw).__name__}"
71-
)
72-
)
73-
for item in raw:
74-
if not isinstance(item, dict):
75-
raise exc.OpenAEVParsingError(
76-
error_message=(
77-
f"Expected AI expectation objects (dicts) from {path}, "
78-
f"got a list element of type {type(item).__name__}"
79-
)
80-
)
81-
return [item for item in raw]
114+
return _as_list_of_dicts(raw, path, "AI expectations", "AI expectation")
115+
116+
@exc.on_http_error(exc.OpenAEVListError)
117+
def logging_expectations_for_source(
118+
self, source_id: str, **kwargs: Any
119+
) -> List[Dict[str, Any]]:
120+
"""Returns the LOGGING expectations not yet filled for the given source.
121+
122+
Only collectors that declare LOGGING in their supported expectation
123+
types receive them. An older platform without the LOGGING route answers
124+
404: it is treated as "no LOGGING expectation", with a warning.
125+
126+
:param source_id: the identifier of the collector requesting expectations
127+
:type source_id: str
128+
:raises OpenAEVParsingError: if the server does not return a JSON list of dicts
129+
:return: a list of logging expectation dicts
130+
:rtype: list[dict]
131+
"""
132+
path = f"{self.path}/logging/{source_id}"
133+
try:
134+
raw: Any = self.openaev.http_get(path, **kwargs)
135+
except exc.OpenAEVHttpError as err:
136+
if err.response_code != 404:
137+
raise
138+
self._report_missing_logging_route(path)
139+
return []
140+
return _as_list_of_dicts(
141+
raw, path, "Logging expectations", "Logging expectation"
142+
)
143+
144+
def _report_missing_logging_route(self, path: str) -> None:
145+
if self._logging_route_missing_reported:
146+
logger.debug("%s answered 404, no LOGGING expectation collected", path)
147+
return
148+
self._logging_route_missing_reported = True
149+
logger.warning(
150+
"%s answered 404: this OpenAEV platform does not support LOGGING "
151+
"expectations yet, none will be collected",
152+
path,
153+
)
154+
155+
def logging_expectation_models_for_source(
156+
self, source_id: str, **kwargs: Any
157+
) -> List[LoggingExpectation]:
158+
"""Returns the LOGGING expectations from OpenAEV that have had no result
159+
yet from the source_id (e.g. collector), as models.
160+
161+
:param source_id: the identifier of the collector requesting expectations
162+
:type source_id: str
163+
:param kwargs: additional data to pass to the endpoint
164+
:type kwargs: dict, optional
165+
166+
:return: a list of expectation objects
167+
:rtype: list[LoggingExpectation]
168+
"""
169+
return self._models_from_dicts(
170+
self.logging_expectations_for_source(source_id=source_id, **kwargs),
171+
_LOGGING_EXPECTATION_MODELS,
172+
)
82173

83-
def expectations_models_for_source(self, source_id: str, **kwargs: Any):
174+
def expectations_models_for_source(
175+
self, source_id: str, **kwargs: Any
176+
) -> List[Expectation]:
84177
"""Returns all expectations from OpenAEV that have had no result yet
85178
from the source_id (e.g. collector).
86179
@@ -92,20 +185,36 @@ def expectations_models_for_source(self, source_id: str, **kwargs: Any):
92185
:return: a list of expectation objects
93186
:rtype: list[DetectionExpectation|PreventionExpectation]
94187
"""
95-
# TODO: we should implement a more clever mechanism to obtain
96-
# specialised Expectation instances rather than just if/elseing
97-
# through this list of possibilities.
98-
expectations = []
99-
for expectation_dict in self.expectations_assets_for_source(
100-
source_id=source_id, **kwargs
101-
):
102-
if (
103-
expectation_dict["inject_expectation_type"]
104-
== ExpectationTypeEnum.Detection.value
105-
):
106-
expectation_class = DetectionExpectation
107-
else:
108-
expectation_class = PreventionExpectation
188+
return self._models_from_dicts(
189+
self.expectations_assets_for_source(source_id=source_id, **kwargs),
190+
_ASSET_EXPECTATION_MODELS,
191+
)
192+
193+
def _models_from_dicts(
194+
self,
195+
expectation_dicts: List[Dict[str, Any]],
196+
model_by_type: Dict[str, Type[ExpectationModel]],
197+
) -> List[ExpectationModel]:
198+
"""Builds the model of each expectation dict, by expectation type.
199+
200+
A type missing from model_by_type is skipped with a warning, never
201+
mapped to another model: a collector must not answer an expectation
202+
type it does not handle.
203+
"""
204+
expectations: List[ExpectationModel] = []
205+
for expectation_dict in expectation_dicts:
206+
expectation_type: str = (
207+
expectation_dict.get("inject_expectation_type") or ""
208+
)
209+
expectation_class = model_by_type.get(expectation_type)
210+
if expectation_class is None:
211+
logger.warning(
212+
"Skipping expectation %s, its type %s is not one of %s",
213+
expectation_dict.get("inject_expectation_id"),
214+
expectation_type,
215+
", ".join(model_by_type),
216+
)
217+
continue
109218
# One invalid expectation (e.g. an unknown signature type) must not
110219
# hide the valid ones from the collector.
111220
try:
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
from .expectation import (
22
DetectionExpectation,
33
ExpectationTypeEnum,
4+
LoggingExpectation,
45
PreventionExpectation,
56
)
67

7-
__all__ = ["DetectionExpectation", "ExpectationTypeEnum", "PreventionExpectation"]
8+
__all__ = [
9+
"DetectionExpectation",
10+
"ExpectationTypeEnum",
11+
"LoggingExpectation",
12+
"PreventionExpectation",
13+
]

‎pyoaev/apis/inject_expectation/model/expectation.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class ExpectationTypeEnum(str, Enum):
1414

1515
Detection = "DETECTION"
1616
Prevention = "PREVENTION"
17+
Logging = "LOGGING"
1718
Vulnerability = "VULNERABILITY"
1819
Other = "other"
1920

@@ -181,3 +182,16 @@ class PreventionExpectation(Expectation):
181182

182183
success_label: str = "Prevented"
183184
failure_label: str = "Not Prevented"
185+
186+
187+
class LoggingExpectation(Expectation):
188+
"""An expectation that is specific to Logging, i.e. that is used
189+
by OpenAEV to assert that an inject's execution was recorded by the
190+
log sources of a security platform, whether it raised an alert or not.
191+
192+
Collectors only report successes for this type: "Not Logged" is written
193+
by the platform when the expectation expires.
194+
"""
195+
196+
success_label: str = "Logged"
197+
failure_label: str = "Not Logged"

‎pyoaev/apis/inject_expectation_trace.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import Enum
12
from typing import Any, Dict, List
23

34
from pyoaev import exceptions as exc
@@ -6,6 +7,19 @@
67
from pyoaev.utils import RequiredOptional
78

89

10+
class TraceMatchType(str, Enum):
11+
"""Signal that matched a LOGGING expectation, sent as
12+
inject_expectation_trace_match_type.
13+
14+
The platform drops an unknown value without error, so collectors should
15+
use these members rather than raw strings.
16+
"""
17+
18+
MARKER = "marker"
19+
SOURCE_IP = "source_ip"
20+
TARGET_IP = "target_ip"
21+
22+
923
class InjectExpectationTrace(RESTObject):
1024
_id_attr = "inject_expectation_trace_id"
1125

@@ -21,6 +35,11 @@ class InjectExpectationTraceManager(CreateMixin, RESTManager):
2135
"inject_expectation_trace_alert_link",
2236
"inject_expectation_trace_date",
2337
),
38+
optional=(
39+
# Signal that matched, for a LOGGING expectation: a TraceMatchType.
40+
# Left out for the other types.
41+
"inject_expectation_trace_match_type",
42+
),
2443
)
2544

2645
@exc.on_http_error(exc.OpenAEVUpdateError)

‎pyoaev/contracts/contract_config.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,17 @@ class ExpectationType(str, Enum):
123123
manual: str = "MANUAL"
124124
prevention: str = "PREVENTION"
125125
detection: str = "DETECTION"
126+
logging: str = "LOGGING"
126127
vulnerability: str = "VULNERABILITY"
127128

128129

129130
class SecurityPlatformType(str, Enum):
130131
"""Categories of security platform expected to fulfil a technical expectation.
131132
132133
When an expectation declares one or more of these, the platform focuses the
133-
technical (DETECTION / PREVENTION / VULNERABILITY) result on collectors of
134-
those types only (instead of every connected security platform). An empty
135-
list means "any security platform".
134+
technical (DETECTION / PREVENTION / LOGGING / VULNERABILITY) result on
135+
collectors of those types only (instead of every connected security
136+
platform). An empty list means "any security platform".
136137
"""
137138

138139
EDR: str = "EDR"
@@ -156,7 +157,8 @@ class Expectation:
156157
expectation_is_multi_selectable: bool = False
157158
# Security platform types expected to fulfil this expectation. Empty = any
158159
# platform (unchanged behaviour). Typically set for technical expectations
159-
# (DETECTION / PREVENTION / VULNERABILITY), left empty for MANUAL ones.
160+
# (DETECTION / PREVENTION / LOGGING / VULNERABILITY), left empty for MANUAL
161+
# ones.
160162
expectation_expected_security_platform_types: List[SecurityPlatformType] = field(
161163
default_factory=list
162164
)

0 commit comments

Comments
 (0)