Skip to content

Commit b41ab06

Browse files
authored
[client] Refactor helpers to expose encapsulated and tested components for building collectors (#75)
Signed-off-by: Antoine MAZEAS <[email protected]>
1 parent 8312335 commit b41ab06

31 files changed

+1610
-240
lines changed

.circleci/config.yml

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,7 @@ jobs:
1212
- checkout
1313
- run:
1414
name: install dependencies
15-
command: pip3 install -r requirements.txt --user
16-
- run:
17-
name: install test-dependencies
18-
command: pip3 install -r test-requirements.txt --user
15+
command: pip3 install -r requirements.txt -r test-requirements.txt --user
1916
- run:
2017
name: confirm black version
2118
command: black --version
@@ -41,9 +38,6 @@ jobs:
4138
- run:
4239
name: install dependencies
4340
command: pip3 install -r requirements.txt --user
44-
- run:
45-
name: install test-dependencies
46-
command: pip3 install -r test-requirements.txt --user
4741
- run:
4842
name: run tests
4943
command: python -m unittest
@@ -72,7 +66,7 @@ jobs:
7266
- run:
7367
name: install dependencies
7468
command: >
75-
pip3 install --user -r requirements.txt -r test-requirements.txt
69+
pip3 install --user -r requirements.txt
7670
- run:
7771
name: check version
7872
command: |
@@ -172,6 +166,7 @@ workflows:
172166
requires:
173167
- ensure_formatting
174168
- linter
169+
- test
175170
- deploy:
176171
requires:
177172
- build

README.md

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ $ python3 -m pip install -e .[dev,doc]
3636
$ pre-commit install
3737
# Create your feature/fix
3838
# Create tests for your changes
39-
$ pytest
39+
$ python -m unittest
4040
# Push you feature/fix on Github
4141
$ git add [file(s)]
4242
$ git commit -m "[descriptive message]"
@@ -62,31 +62,10 @@ To learn about the methods available for executing queries and retrieving their
6262

6363
## Tests
6464

65-
### Install dependencies
65+
The standard `unittest` library is used for running the tests.
6666

6767
```bash
68-
$ pip install -r ./test-requirements.txt
69-
```
70-
71-
[pytest](https://docs.pytest.org/en/7.2.x/) is used to launch the tests.
72-
73-
### Launch tests
74-
75-
#### Prerequisite
76-
77-
Your OpenBAS API should be running.
78-
Your conftest.py should be configured with your API url, your token, and if applicable, your mTLS cert/key.
79-
80-
#### Launching
81-
82-
Unit tests
83-
```bash
84-
$ pytest ./tests/01-unit/
85-
```
86-
87-
Integration testing
88-
```bash
89-
$ pytest ./tests/02-integration/
68+
$ python -m unittest
9069
```
9170

9271
## About

docs/conf.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@
1818

1919
# -- Project information -----------------------------------------------------
2020

21-
project = "OpenCTI client for Python"
21+
project = "OpenBAS client for Python"
2222
copyright = "2024, Filigran"
23-
author = "OpenCTI Project"
23+
author = "OpenBAS Project"
2424

2525
# The full version, including alpha/beta/rc tags
26-
release = "5.12.20"
26+
release = "1.10.1"
2727

2828
master_doc = "index"
2929

docs/index.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
OpenBAS client for Python
22
=========================
33

4-
The pycti library is designed to help OpenBAS users and developers to interact
5-
with the OpenBAS platform GraphQL API.
4+
The pyobas library is designed to help OpenBAS users and developers to interact
5+
with the OpenBAS platform API.
66

77
The Python library requires Python >= 3.
88

@@ -11,7 +11,7 @@ The Python library requires Python >= 3.
1111
:caption: Contents:
1212

1313
client_usage/getting_started.rst
14-
pycti/pycti
14+
pyobas/pyobas
1515

1616

1717
Indices and tables

pyobas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
__title__,
1010
)
1111
from pyobas.client import OpenBAS # noqa: F401
12+
from pyobas.configuration import * # noqa: F401,F403,F405
1213
from pyobas.contracts import * # noqa: F401,F403,F405
1314
from pyobas.exceptions import * # noqa: F401,F403,F405
15+
from pyobas.signatures import * # noqa: F401,F403,F405
1416

1517
__all__ = [
1618
"__author__",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .inject_expectation import * # noqa: F401,F403

pyobas/apis/inject_expectation.py renamed to pyobas/apis/inject_expectation/inject_expectation.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from typing import Any, Dict
22

33
from pyobas import exceptions as exc
4+
from pyobas.apis.inject_expectation.model import (
5+
DetectionExpectation,
6+
ExpectationTypeEnum,
7+
PreventionExpectation,
8+
)
49
from pyobas.base import RESTManager, RESTObject
510
from pyobas.mixins import ListMixin, UpdateMixin
611
from pyobas.utils import RequiredOptional
@@ -29,6 +34,45 @@ def expectations_assets_for_source(
2934
result = self.openbas.http_get(path, **kwargs)
3035
return result
3136

37+
def expectations_models_for_source(self, source_id: str, **kwargs: Any):
38+
"""Returns all expectations from OpenBAS that have had no result yet
39+
from the source_id (e.g. collector).
40+
41+
:param source_id: the identifier of the collector requesting expectations
42+
:type source_id: str
43+
:param kwargs: additional data to pass to the endpoint
44+
:type kwargs: dict, optional
45+
46+
:return: a list of expectation objects
47+
:rtype: list[DetectionExpectation|PreventionExpectation]
48+
"""
49+
# TODO: we should implement a more clever mechanism to obtain
50+
# specialised Expectation instances rather than just if/elseing
51+
# through this list of possibilities.
52+
expectations = []
53+
for expectation_dict in self.expectations_assets_for_source(
54+
source_id=source_id, **kwargs
55+
):
56+
if (
57+
expectation_dict["inject_expectation_type"]
58+
== ExpectationTypeEnum.Detection.value
59+
):
60+
expectations.append(
61+
DetectionExpectation(**expectation_dict, api_client=self)
62+
)
63+
elif (
64+
expectation_dict["inject_expectation_type"]
65+
== ExpectationTypeEnum.Prevention.value
66+
):
67+
expectations.append(
68+
PreventionExpectation(**expectation_dict, api_client=self)
69+
)
70+
else:
71+
expectations.append(
72+
PreventionExpectation(**expectation_dict, api_client=self)
73+
)
74+
return expectations
75+
3276
@exc.on_http_error(exc.OpenBASUpdateError)
3377
def prevention_expectations_for_source(
3478
self, source_id: str, **kwargs: Any
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .expectation import (
2+
DetectionExpectation,
3+
ExpectationTypeEnum,
4+
PreventionExpectation,
5+
)
6+
7+
__all__ = ["DetectionExpectation", "ExpectationTypeEnum", "PreventionExpectation"]
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
from enum import Enum
2+
from typing import List
3+
from uuid import UUID
4+
5+
from pydantic import BaseModel
6+
from thefuzz import fuzz
7+
8+
from pyobas.signatures.signature_type import SignatureType
9+
from pyobas.signatures.types import MatchTypes, SignatureTypes
10+
11+
12+
class ExpectationTypeEnum(str, Enum):
13+
"""Types of Expectations"""
14+
15+
Detection = "DETECTION"
16+
Prevention = "PREVENTION"
17+
Other = "other"
18+
19+
@classmethod
20+
def _missing_(cls, value):
21+
return cls.Other
22+
23+
24+
class ExpectationSignature(BaseModel):
25+
"""An expectation signature describes a known marker potentially
26+
found in alerting data in security software. For example, an
27+
expectation signature can be a process image name, a command
28+
line, or any other relevant piece of data.
29+
"""
30+
31+
type: SignatureTypes
32+
value: str
33+
34+
35+
class Expectation(BaseModel):
36+
"""An expectation represents an expected outcome of a BAS run.
37+
For example, in the case of running an attack command line, the
38+
expectation may be that security software has _detected_ it, while
39+
another expectation may be that the attack was _prevented_.
40+
"""
41+
42+
inject_expectation_id: UUID
43+
inject_expectation_signatures: List[ExpectationSignature]
44+
45+
success_label: str = "Success"
46+
failure_label: str = "Failure"
47+
48+
def __init__(self, *a, **kw):
49+
super().__init__(*a, **kw)
50+
self.__api_client = kw["api_client"]
51+
52+
def update(self, success, sender_id, metadata):
53+
"""Update the expectation object in OpenBAS with the supplied outcome.
54+
55+
:param success: whether the expectation was fulfilled (true) or not (false)
56+
:type success: bool
57+
:param sender_id: identifier of the collector that is updating the expectation
58+
:type sender_id: string
59+
:param metadata: arbitrary dictionary of additional data relevant to updating the expectation
60+
:type metadata: dict[string,string]
61+
"""
62+
self.__api_client.update(
63+
self.inject_expectation_id,
64+
inject_expectation={
65+
"collector_id": sender_id,
66+
"result": (self.success_label if success else self.failure_label),
67+
"is_success": success,
68+
"metadata": metadata,
69+
},
70+
)
71+
72+
def match_alert(self, relevant_signature_types: list[SignatureType], alert_data):
73+
"""Matches an alert's data against the current expectation signatures
74+
to see if the alert is relevant to the current expectation's inject,
75+
i.e. this alert was triggered by the execution of the inject to which
76+
belongs the expectation.
77+
78+
:param relevant_signature_types: filter of signature types that we want to consider.
79+
Only the signature types listed in this collection may be checked for matching.
80+
:type relevant_signature_types: list[SignatureType]
81+
:param alert_data: list of possibly relevant markers found in an alert.
82+
:type alert_data: dict[SignatureTypes, dict]
83+
84+
:return: whether the alert matches the expectation signatures or not.
85+
:rtype: bool
86+
"""
87+
relevant_expectation_signatures = [
88+
signature
89+
for signature in self.inject_expectation_signatures
90+
if signature.type in [type.label for type in relevant_signature_types]
91+
]
92+
if not any(relevant_expectation_signatures):
93+
return False
94+
95+
for relevant_expectation_signature in relevant_expectation_signatures:
96+
if not (
97+
alert_signature_for_type := alert_data.get(
98+
relevant_expectation_signature.type.value
99+
)
100+
):
101+
return False
102+
103+
if alert_signature_for_type[
104+
"type"
105+
] == MatchTypes.MATCH_TYPE_FUZZY and not self.match_fuzzy(
106+
alert_signature_for_type["data"],
107+
relevant_expectation_signature.value,
108+
alert_signature_for_type["score"],
109+
):
110+
return False
111+
if alert_signature_for_type[
112+
"type"
113+
] == MatchTypes.MATCH_TYPE_SIMPLE and not self.match_simple(
114+
alert_signature_for_type["data"], relevant_expectation_signature.value
115+
):
116+
return False
117+
118+
return True
119+
120+
@staticmethod
121+
def match_fuzzy(tested: list[str], reference: str, threshold: int):
122+
"""Applies a fuzzy match against a known reference to a list of candidates
123+
124+
:param tested: list of strings candidate for fuzzy matching
125+
:type tested: list[str]
126+
:param reference: the reference against which to try to fuzzy match
127+
:type reference: str
128+
:param threshold: string overlap percentage threshold above which to declare a match
129+
:type threshold: int
130+
131+
:return: whether any of the candidate is a match against the reference
132+
:rtype: bool
133+
"""
134+
actual_tested = [tested] if isinstance(tested, str) else tested
135+
for value in actual_tested:
136+
ratio = fuzz.ratio(value, reference)
137+
if ratio >= threshold:
138+
return True
139+
return False
140+
141+
@staticmethod
142+
def match_simple(tested: list[str], reference: str):
143+
"""A simple strict, case-sensitive string matching between a list of
144+
candidates and a reference.
145+
146+
:param tested: list of strings candidate for fuzzy matching
147+
:type tested: list[str]
148+
:param reference: the reference against which to try to fuzzy match
149+
:type reference: str
150+
151+
:return: whether any of the candidate is a match against the reference
152+
:rtype: bool
153+
"""
154+
return Expectation.match_fuzzy(tested, reference, threshold=100)
155+
156+
157+
class DetectionExpectation(Expectation):
158+
"""An expectation that is specific to Detection, i.e. that is used
159+
by OpenBAS to assert that an inject's execution was detected.
160+
"""
161+
162+
success_label: str = "Detected"
163+
failure_label: str = "Not Detected"
164+
165+
166+
class PreventionExpectation(Expectation):
167+
"""An expectation that is specific to Prevention, i.e. that is used
168+
by OpenBAS to assert that an inject's execution was prevented.
169+
"""
170+
171+
success_label: str = "Prevented"
172+
failure_label: str = "Not Prevented"

pyobas/configuration/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .configuration import Configuration
2+
3+
__all__ = ["Configuration"]

0 commit comments

Comments
 (0)