clue-api 1.0.0.dev7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clue/.gitignore +21 -0
- clue/__init__.py +0 -0
- clue/api/__init__.py +211 -0
- clue/api/base.py +99 -0
- clue/api/v1/__init__.py +82 -0
- clue/api/v1/actions.py +92 -0
- clue/api/v1/auth.py +243 -0
- clue/api/v1/configs.py +83 -0
- clue/api/v1/fetchers.py +94 -0
- clue/api/v1/lookup.py +221 -0
- clue/api/v1/registration.py +109 -0
- clue/api/v1/static.py +94 -0
- clue/app.py +166 -0
- clue/cache/__init__.py +129 -0
- clue/common/__init__.py +0 -0
- clue/common/classification.py +1006 -0
- clue/common/classification.yml +130 -0
- clue/common/dict_utils.py +130 -0
- clue/common/exceptions.py +199 -0
- clue/common/forge.py +152 -0
- clue/common/json_utils.py +10 -0
- clue/common/list_utils.py +11 -0
- clue/common/logging/__init__.py +291 -0
- clue/common/logging/audit.py +157 -0
- clue/common/logging/format.py +42 -0
- clue/common/regex.py +31 -0
- clue/common/str_utils.py +213 -0
- clue/common/swagger.py +139 -0
- clue/common/uid.py +47 -0
- clue/config.py +60 -0
- clue/constants/__init__.py +0 -0
- clue/constants/supported_types.py +38 -0
- clue/cronjobs/__init__.py +30 -0
- clue/cronjobs/plugins.py +32 -0
- clue/error.py +129 -0
- clue/gunicorn_config.py +29 -0
- clue/healthz.py +74 -0
- clue/helper/discover.py +53 -0
- clue/helper/headers.py +30 -0
- clue/helper/oauth.py +128 -0
- clue/models/__init__.py +0 -0
- clue/models/actions.py +243 -0
- clue/models/config.py +456 -0
- clue/models/fetchers.py +136 -0
- clue/models/graph.py +162 -0
- clue/models/model_list.py +52 -0
- clue/models/network.py +430 -0
- clue/models/results/__init__.py +34 -0
- clue/models/results/base.py +10 -0
- clue/models/results/graph.py +26 -0
- clue/models/results/image.py +22 -0
- clue/models/results/status.py +55 -0
- clue/models/results/validation.py +57 -0
- clue/models/selector.py +67 -0
- clue/models/utils.py +52 -0
- clue/models/validators.py +19 -0
- clue/patched.py +8 -0
- clue/plugin/__init__.py +1008 -0
- clue/plugin/helpers/__init__.py +0 -0
- clue/plugin/helpers/central_server.py +27 -0
- clue/plugin/helpers/email_render.py +228 -0
- clue/plugin/helpers/token.py +34 -0
- clue/plugin/helpers/trino.py +103 -0
- clue/plugin/interactive.py +270 -0
- clue/plugin/models.py +19 -0
- clue/plugin/utils.py +78 -0
- clue/remote/__init__.py +0 -0
- clue/remote/datatypes/__init__.py +130 -0
- clue/remote/datatypes/cache.py +62 -0
- clue/remote/datatypes/events.py +118 -0
- clue/remote/datatypes/hash.py +193 -0
- clue/remote/datatypes/queues/__init__.py +0 -0
- clue/remote/datatypes/queues/comms.py +62 -0
- clue/remote/datatypes/set.py +96 -0
- clue/remote/datatypes/user_quota_tracker.py +54 -0
- clue/security/__init__.py +211 -0
- clue/security/obo.py +95 -0
- clue/security/utils.py +34 -0
- clue/services/action_service.py +186 -0
- clue/services/auth_service.py +348 -0
- clue/services/config_service.py +38 -0
- clue/services/fetcher_service.py +203 -0
- clue/services/jwt_service.py +233 -0
- clue/services/lookup_service.py +786 -0
- clue/services/type_service.py +165 -0
- clue/services/user_service.py +152 -0
- clue_api-1.0.0.dev7.dist-info/METADATA +111 -0
- clue_api-1.0.0.dev7.dist-info/RECORD +91 -0
- clue_api-1.0.0.dev7.dist-info/WHEEL +4 -0
- clue_api-1.0.0.dev7.dist-info/entry_points.txt +8 -0
- clue_api-1.0.0.dev7.dist-info/licenses/LICENSE +11 -0
clue/models/graph.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# ruff: noqa: D101
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any, Literal, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
from pydantic.alias_generators import to_camel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseGraphModel(BaseModel):
|
|
11
|
+
model_config = ConfigDict(
|
|
12
|
+
alias_generator=to_camel,
|
|
13
|
+
populate_by_name=True,
|
|
14
|
+
extra="forbid",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Operator(StrEnum):
|
|
19
|
+
equals = "="
|
|
20
|
+
less_than = "<"
|
|
21
|
+
greater_than = ">"
|
|
22
|
+
not_equals = "!="
|
|
23
|
+
less_than_equal_to = "<="
|
|
24
|
+
greater_than_equal_to = ">="
|
|
25
|
+
includes = "~"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Comparator(BaseGraphModel):
|
|
29
|
+
field: str | None = Field(description="What field should the comparator check against?", default=None)
|
|
30
|
+
operator: Operator | None = Field(
|
|
31
|
+
description="What operation should the comparator use for this check?", default=None
|
|
32
|
+
)
|
|
33
|
+
value: str | int | bool | float | None = Field(
|
|
34
|
+
description="What value should the comparator check the field against?", default=None
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DisplayField(Comparator):
|
|
39
|
+
zoom: float | None = Field(description="What zoom level should the field be shown at?", default=None)
|
|
40
|
+
label: str = Field(description="What value should be shown from the node in this display field?")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Type(StrEnum):
|
|
44
|
+
node = "node"
|
|
45
|
+
edge = "edge"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Style(Comparator):
|
|
49
|
+
color: str | None = Field(description="What colour should be applied to the matching elements?", default=None)
|
|
50
|
+
size: float | None = Field(description="What size should the matching elements be?", default=None)
|
|
51
|
+
type: Type = Field(description="What type of element do you want to select?")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NodeColor(BaseGraphModel):
|
|
55
|
+
border: str = Field(description="What default colour should the border of the nodes have?", default="grey")
|
|
56
|
+
center: str = Field(description="What default colour should the center of the nodes have?", default="white")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class VisualConfig(BaseGraphModel):
|
|
60
|
+
text_color: str = Field(description="What colour should the text on the graph be?", default="white")
|
|
61
|
+
node_color: NodeColor = NodeColor()
|
|
62
|
+
letter_size: float = Field(description="What size should the text on the graph be?", default=10)
|
|
63
|
+
letter_width: float = Field(description="What size should the text on the graph be?", default=6.5)
|
|
64
|
+
x_spacing: float = Field(description="How much spacing should there be between columns in the graph?", default=8)
|
|
65
|
+
y_spacing: float = Field(description="How much spacing should there be between rows in the graph?", default=20)
|
|
66
|
+
node_radius: float = Field(description="How large should nodes on the graph be?", default=6)
|
|
67
|
+
max_arc_radius: float = Field(description="How gradual should curves of the edges of the graph be?", default=8)
|
|
68
|
+
line_padding_x: float = Field(
|
|
69
|
+
description="How much horizontal padding should there be between edges on the graph?", default=10
|
|
70
|
+
)
|
|
71
|
+
line_padding_y: float = Field(
|
|
72
|
+
description="How much vertical padding should there be between edges on the graph?", default=10
|
|
73
|
+
)
|
|
74
|
+
line_width: float = Field(description="How thick should edges on the graph be?", default=3)
|
|
75
|
+
enable_entry_padding: bool = Field(
|
|
76
|
+
description="Should entries be separated by the length of the longest label?", default=True
|
|
77
|
+
)
|
|
78
|
+
truncate_labels: bool = Field(description="Should labels be truncated?", default=True)
|
|
79
|
+
enable_timestamps: bool = Field(
|
|
80
|
+
description="Should timestamps be enabled? This will also show annotations, if provided", default=True
|
|
81
|
+
)
|
|
82
|
+
icon_orientation: Optional[Literal["vertical"] | Literal["horizontal"]] = Field(
|
|
83
|
+
description="Where should icons be rendered when provided - above or to the side of the node?",
|
|
84
|
+
default="vertical",
|
|
85
|
+
)
|
|
86
|
+
line_direction: Optional[Literal["vertical"] | Literal["horizontal"]] = Field(
|
|
87
|
+
description="What pathing behaviour should the edges of the graph use by default?",
|
|
88
|
+
default="horizontal",
|
|
89
|
+
)
|
|
90
|
+
row_step: float = Field(
|
|
91
|
+
description=(
|
|
92
|
+
"How much spacing should there be between rows? This is calculated dynamically if not "
|
|
93
|
+
"provided, based on y_spacing"
|
|
94
|
+
),
|
|
95
|
+
default=0,
|
|
96
|
+
)
|
|
97
|
+
below_previous: bool = Field(
|
|
98
|
+
description="Should each column be rendered below the previous?",
|
|
99
|
+
default=False,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@model_validator(mode="before")
|
|
103
|
+
@classmethod
|
|
104
|
+
def prepare_model(cls, data: dict[str, Any]) -> dict[str, str]: # noqa: ANN102
|
|
105
|
+
"""Calculates the row step if not provided.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
data (dict[str, str]): The data to validate.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
dict[str, str]: The data including the password.
|
|
112
|
+
"""
|
|
113
|
+
if "rowStep" not in data:
|
|
114
|
+
if "ySpacing" in data:
|
|
115
|
+
data["rowStep"] = 2 * data["ySpacing"]
|
|
116
|
+
elif "y_spacing" in data:
|
|
117
|
+
data["rowStep"] = 2 * data["y_spacing"]
|
|
118
|
+
else:
|
|
119
|
+
data["rowStep"] = 2 * VisualConfig.model_fields["y_spacing"].default
|
|
120
|
+
|
|
121
|
+
return data
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Visualization(BaseGraphModel):
|
|
125
|
+
config: VisualConfig = VisualConfig()
|
|
126
|
+
type: Literal["tree"] | Literal["cloud"] = Field(
|
|
127
|
+
description="The type of visualization to use. Defaults to tree", default="tree"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DisplayConfig(BaseGraphModel):
|
|
132
|
+
display_field: list[DisplayField] = Field(description="A list of fields to present in the graph.", default=[])
|
|
133
|
+
styles: list[Style] = Field(description="A list of styles to apply to the graph.", default=[])
|
|
134
|
+
visualization: Visualization = Visualization()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Node(BaseGraphModel):
|
|
138
|
+
model_config = ConfigDict(extra="allow")
|
|
139
|
+
|
|
140
|
+
id: str = Field(description="The ID of this node.")
|
|
141
|
+
edges: list[str] = Field(description="A list of IDs this node connects to.", default=[])
|
|
142
|
+
markdown: str | None = Field(
|
|
143
|
+
description="A markdown string providing additional details on this node.", default=None
|
|
144
|
+
)
|
|
145
|
+
timestamp: datetime | None = Field(
|
|
146
|
+
description="The timestamp associated with this node. Only rendered if enable_timestamps is set to true.",
|
|
147
|
+
default=None,
|
|
148
|
+
)
|
|
149
|
+
annotation: str | None = Field(
|
|
150
|
+
description="The annotation associated with this node. Only rendered if enable_timestamps is set to true.",
|
|
151
|
+
default=None,
|
|
152
|
+
)
|
|
153
|
+
icons: list[str] = Field(description="A list of icons to show next to this node.", default=[])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Metadata(BaseGraphModel):
|
|
157
|
+
# TODO: Eventually support non-nested data
|
|
158
|
+
type: Literal["nested"] = "nested"
|
|
159
|
+
display: DisplayConfig = DisplayConfig()
|
|
160
|
+
subgraphs: list[Comparator] = Field(
|
|
161
|
+
description="A list of comparators used to automatically generate subgraphs in the fetcher.", default=[]
|
|
162
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from clue.models.actions import Action, ActionBase, ActionResult, ActionSpec, ExecuteRequest
|
|
6
|
+
from clue.models.fetchers import FetcherDefinition, FetcherResult
|
|
7
|
+
from clue.models.graph import (
|
|
8
|
+
BaseGraphModel,
|
|
9
|
+
Comparator,
|
|
10
|
+
DisplayConfig,
|
|
11
|
+
DisplayField,
|
|
12
|
+
Metadata,
|
|
13
|
+
Node,
|
|
14
|
+
NodeColor,
|
|
15
|
+
Style,
|
|
16
|
+
VisualConfig,
|
|
17
|
+
)
|
|
18
|
+
from clue.models.network import Annotation, ClueResponse, QueryEntry, QueryResult
|
|
19
|
+
from clue.models.results.graph import GraphResult
|
|
20
|
+
from clue.models.results.image import ImageResult
|
|
21
|
+
from clue.models.selector import Selector
|
|
22
|
+
|
|
23
|
+
__MODEL_LIST: list[type[BaseModel]] = [
|
|
24
|
+
Action,
|
|
25
|
+
ActionBase,
|
|
26
|
+
ActionResult,
|
|
27
|
+
ActionSpec,
|
|
28
|
+
ExecuteRequest,
|
|
29
|
+
FetcherDefinition,
|
|
30
|
+
ImageResult,
|
|
31
|
+
FetcherResult,
|
|
32
|
+
BaseGraphModel,
|
|
33
|
+
Comparator,
|
|
34
|
+
DisplayConfig,
|
|
35
|
+
DisplayField,
|
|
36
|
+
GraphResult,
|
|
37
|
+
Metadata,
|
|
38
|
+
Node,
|
|
39
|
+
NodeColor,
|
|
40
|
+
Style,
|
|
41
|
+
VisualConfig,
|
|
42
|
+
ClueResponse,
|
|
43
|
+
Annotation,
|
|
44
|
+
QueryEntry,
|
|
45
|
+
QueryResult,
|
|
46
|
+
Selector,
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
MODEL_SCHEMAS: dict[str, dict[str, Any]] = {}
|
|
50
|
+
|
|
51
|
+
for __model in __MODEL_LIST:
|
|
52
|
+
MODEL_SCHEMAS[__model.__name__] = __model.model_json_schema()
|
clue/models/network.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
# ruff: noqa: D101
|
|
2
|
+
import hashlib
|
|
3
|
+
import textwrap
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from email.utils import parseaddr
|
|
6
|
+
from math import floor
|
|
7
|
+
from random import randbytes, sample
|
|
8
|
+
from typing import Any, Literal, Optional, Union
|
|
9
|
+
|
|
10
|
+
from pydantic import (
|
|
11
|
+
AliasGenerator,
|
|
12
|
+
BaseModel,
|
|
13
|
+
ConfigDict,
|
|
14
|
+
Field,
|
|
15
|
+
ValidationInfo,
|
|
16
|
+
computed_field,
|
|
17
|
+
field_validator,
|
|
18
|
+
model_validator,
|
|
19
|
+
)
|
|
20
|
+
from pydantic_core import Url
|
|
21
|
+
from typing_extensions import Self
|
|
22
|
+
|
|
23
|
+
from clue.common.logging import get_logger
|
|
24
|
+
from clue.config import CLASSIFICATION, DEBUG, get_version
|
|
25
|
+
from clue.constants.supported_types import SUPPORTED_TYPES
|
|
26
|
+
from clue.models.validators import validate_classification
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__file__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClueResponse(BaseModel):
|
|
32
|
+
model_config = ConfigDict(
|
|
33
|
+
alias_generator=AliasGenerator(serialization_alias=lambda field_name: f"api_{field_name}")
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
response: Any = None
|
|
37
|
+
error_message: Optional[str] = None
|
|
38
|
+
warning: list[str] = []
|
|
39
|
+
server_version: str = get_version()
|
|
40
|
+
status_code: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Annotation(BaseModel):
|
|
44
|
+
analytic: Optional[str] = Field(
|
|
45
|
+
description="Identifier for the analytic producing the knowledge. Mutually exclusive with author.",
|
|
46
|
+
default=None,
|
|
47
|
+
examples=["Howler", "Assemblyline", None],
|
|
48
|
+
)
|
|
49
|
+
analytic_icon: Optional[str] = Field(
|
|
50
|
+
description="Formatted string to present an icon for this analytic on the UI using iconify/react format: "
|
|
51
|
+
"https://iconify.design/docs/icon-components/react/. External icons not yet supported",
|
|
52
|
+
default=None,
|
|
53
|
+
examples=["material-symbols:sound-detection-dog-barking", None],
|
|
54
|
+
)
|
|
55
|
+
author: Optional[str] = Field(
|
|
56
|
+
description="The author providing the annotation. Mutually exclusive with analytic.",
|
|
57
|
+
default=None,
|
|
58
|
+
examples=["John Smith", None],
|
|
59
|
+
)
|
|
60
|
+
quantity: int = Field(
|
|
61
|
+
description="Number of times this annotation was generated for the given indicator",
|
|
62
|
+
default=1,
|
|
63
|
+
examples=[1, 10, 25],
|
|
64
|
+
)
|
|
65
|
+
version: Optional[str] = Field(
|
|
66
|
+
description="The version of the API for the analytic that produced the knowledge",
|
|
67
|
+
default=None,
|
|
68
|
+
examples=["v0.0.1", "1.0.0", None],
|
|
69
|
+
)
|
|
70
|
+
timestamp: Optional[datetime] = Field(
|
|
71
|
+
description="A timestamp describing when the knowledge was generated.",
|
|
72
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
73
|
+
examples=[datetime.now(timezone.utc), datetime.now(timezone.utc) - timedelta(weeks=2)],
|
|
74
|
+
)
|
|
75
|
+
type: Literal["opinion", "frequency", "assessment", "mitigation", "context"] = Field(
|
|
76
|
+
description=textwrap.dedent("""
|
|
77
|
+
What type of annotation is this?
|
|
78
|
+
Opinion (What type of activity is the selector associated with?):
|
|
79
|
+
benign - authorized or harmless activity
|
|
80
|
+
suspect - outlier activity without clear intent
|
|
81
|
+
malicious - intended to cause harm
|
|
82
|
+
obscure - why a reliable opinion might not be possible
|
|
83
|
+
|
|
84
|
+
Context (Unopinionated facts)
|
|
85
|
+
Examples:
|
|
86
|
+
- Frequency of observation based on summaries
|
|
87
|
+
- Account privileges
|
|
88
|
+
- Sign-in characteristics
|
|
89
|
+
- Geo-location
|
|
90
|
+
- Domain ownership
|
|
91
|
+
- Operation Label
|
|
92
|
+
|
|
93
|
+
Assessment (An official position)
|
|
94
|
+
Benign Alert Assessments:
|
|
95
|
+
- Ambiguous
|
|
96
|
+
- Security
|
|
97
|
+
- Development
|
|
98
|
+
- False-positive
|
|
99
|
+
- Legitimate
|
|
100
|
+
|
|
101
|
+
Malicious Alert Assessments:
|
|
102
|
+
- Trivial
|
|
103
|
+
- Recon
|
|
104
|
+
- Attempt
|
|
105
|
+
- Compromise
|
|
106
|
+
- Mitigated
|
|
107
|
+
|
|
108
|
+
Frequency (A numeric value for the frequency a selector has been seen)
|
|
109
|
+
Higher number - more common selector
|
|
110
|
+
Lower number - less common selector
|
|
111
|
+
|
|
112
|
+
Mitigation (Suggested actions available to mitigate harm done by the selector)
|
|
113
|
+
exempt - Selector cannot be mitigated, used for known safe selectors
|
|
114
|
+
alertable - Selector can be alerted on
|
|
115
|
+
blockable - Selector can be blocked
|
|
116
|
+
shareable - Selector can be shared with partners/collaborators
|
|
117
|
+
not-alertable - Selector cannot be alerted on
|
|
118
|
+
not-blockable - Selector cannot be blocked
|
|
119
|
+
not-shareable - Selector cannot be shared with partners/collaborators
|
|
120
|
+
"""),
|
|
121
|
+
examples=["opinion", "frequency", "assessment", "mitigation", "context"],
|
|
122
|
+
)
|
|
123
|
+
value: Union[str, float, int] = Field(
|
|
124
|
+
description="The value associated with the type.",
|
|
125
|
+
examples=[
|
|
126
|
+
"benign",
|
|
127
|
+
"suspect",
|
|
128
|
+
"malicious",
|
|
129
|
+
"obscure",
|
|
130
|
+
"IP Located in Canada",
|
|
131
|
+
"Involved in Operation Cat",
|
|
132
|
+
11,
|
|
133
|
+
42.0,
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
confidence: float = Field(
|
|
137
|
+
description="Self-reported confidence level of the annotation. 0.0 = not confident at all, 1.0 = absolute fact",
|
|
138
|
+
ge=0.0,
|
|
139
|
+
le=1.0,
|
|
140
|
+
examples=[0.0, 0.5, 1.0],
|
|
141
|
+
)
|
|
142
|
+
severity: Optional[float] = Field(
|
|
143
|
+
description="Severity of the annotation, if accurate. 0.0 = not severe at all, 1.0 = extremely important",
|
|
144
|
+
ge=0.0,
|
|
145
|
+
le=1.0,
|
|
146
|
+
default=None,
|
|
147
|
+
examples=[0.0, 0.5, 1.0, None],
|
|
148
|
+
)
|
|
149
|
+
priority: Optional[float] = Field(
|
|
150
|
+
description=(
|
|
151
|
+
"What priority to assign to this annotation. Higher priority = more likely to be shown to analysts. "
|
|
152
|
+
"Optional. If not provided, calculated based on confidence, severity and reliability."
|
|
153
|
+
),
|
|
154
|
+
default=None,
|
|
155
|
+
examples=[1.0, 50.0, 1000.0, None],
|
|
156
|
+
)
|
|
157
|
+
summary: str = Field(
|
|
158
|
+
description="A plaintext summary of the annotation.",
|
|
159
|
+
examples=["Example summary of the information in this Annotation"],
|
|
160
|
+
)
|
|
161
|
+
details: Optional[str] = Field(
|
|
162
|
+
description="detailed description of the annotation. Supports markdown formatting.",
|
|
163
|
+
default=None,
|
|
164
|
+
examples=["# Here's some annotation details\n\nIt's very interesting", None],
|
|
165
|
+
)
|
|
166
|
+
link: Optional[Url] = Field(
|
|
167
|
+
description="Link for more information about this specific annotation",
|
|
168
|
+
default=None,
|
|
169
|
+
examples=[Url("https://example.com/annotation"), None],
|
|
170
|
+
)
|
|
171
|
+
icon: Optional[str] = Field(
|
|
172
|
+
description="Formatted string to present an icon for this annotation on the UI using iconify/react format: "
|
|
173
|
+
"https://iconify.design/docs/icon-components/react/. External icons not yet supported",
|
|
174
|
+
default=None,
|
|
175
|
+
examples=["material-symbols:sound-detection-dog-barking", None],
|
|
176
|
+
)
|
|
177
|
+
ubiquitous: bool = Field(
|
|
178
|
+
description="Does this annotation show up on the vast majority of selectors (i.e. asset provenance, "
|
|
179
|
+
"organization ownership/non-ownership of IP address)",
|
|
180
|
+
default=False,
|
|
181
|
+
examples=[True, False],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@computed_field # type: ignore[misc]
|
|
185
|
+
@property
|
|
186
|
+
def reliability(self: Self) -> Optional[float]:
|
|
187
|
+
"""Accurately calculated reliability of the annotation. 0.0 = not reliable, 1.0 = extremely reliable"""
|
|
188
|
+
if self.author:
|
|
189
|
+
return 1.0
|
|
190
|
+
|
|
191
|
+
# TODO: Implement a way to set reliability of annotations from analytics
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
@model_validator(mode="after")
|
|
195
|
+
def validate_model(self: Self) -> Self: # noqa: C901
|
|
196
|
+
"""Validates the entire model.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
AssertionError: Raised whenever a field is invalid on the model.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Self: The validated model.
|
|
203
|
+
"""
|
|
204
|
+
if not (self.author or self.analytic):
|
|
205
|
+
raise AssertionError("Author or analytic must be set.")
|
|
206
|
+
if self.author and self.analytic:
|
|
207
|
+
raise AssertionError("Author and analytic are mutually exclusive.")
|
|
208
|
+
|
|
209
|
+
if self.type in ["opinion", "assessment", "mitigation", "context"]:
|
|
210
|
+
if not isinstance(self.value, str):
|
|
211
|
+
raise AssertionError(
|
|
212
|
+
f"Value must be a string if type is not frequency. Type is ({type(self.value).__name__})"
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
try:
|
|
216
|
+
self.value = int(self.value)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
raise AssertionError("Value must be an int if type is frequency.") from e
|
|
219
|
+
|
|
220
|
+
if self.type == "opinion":
|
|
221
|
+
valid_options = ["benign", "suspicious", "malicious", "obscure"]
|
|
222
|
+
if self.value not in valid_options:
|
|
223
|
+
raise AssertionError(
|
|
224
|
+
f"If type is opinion, value must be one of ({', '.join(valid_options)}). Value is {self.value}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
elif self.type == "mitigation":
|
|
228
|
+
valid_options = [
|
|
229
|
+
"exempt",
|
|
230
|
+
"alertable",
|
|
231
|
+
"blockable",
|
|
232
|
+
"shareable",
|
|
233
|
+
"not-alertable",
|
|
234
|
+
"not-blockable",
|
|
235
|
+
"not-shareable",
|
|
236
|
+
]
|
|
237
|
+
if self.value not in valid_options:
|
|
238
|
+
raise AssertionError(
|
|
239
|
+
f"If type is mitigation, value must be one of ({', '.join(valid_options)}). Value is {self.value}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
elif self.type == "assessment":
|
|
243
|
+
valid_options = [
|
|
244
|
+
"ambiguous",
|
|
245
|
+
"security",
|
|
246
|
+
"development",
|
|
247
|
+
"false-positive",
|
|
248
|
+
"legitimate",
|
|
249
|
+
"trivial",
|
|
250
|
+
"recon",
|
|
251
|
+
"attempt",
|
|
252
|
+
"compromise",
|
|
253
|
+
"mitigated",
|
|
254
|
+
]
|
|
255
|
+
if self.value not in valid_options:
|
|
256
|
+
raise AssertionError(
|
|
257
|
+
f"If type is assessment, value must be one of ({', '.join(valid_options)}). Value is {self.value}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if self.icon and self.type != "context":
|
|
261
|
+
raise AssertionError("Icons are currently only supported for 'context' annotations.")
|
|
262
|
+
|
|
263
|
+
if self.icon and len(self.icon.split(":")) != 2:
|
|
264
|
+
raise AssertionError("Icon field not formatted correctly. Must be in the format <icon_type>:<icon_id>.")
|
|
265
|
+
|
|
266
|
+
if self.analytic_icon and len(self.analytic_icon.split(":")) != 2:
|
|
267
|
+
raise AssertionError(
|
|
268
|
+
"Analytic Icon field not formatted correctly. Must be in the format <icon_type>:<icon_id>."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if not self.priority and self.severity:
|
|
272
|
+
modifier = self.reliability if self.reliability is not None else self.confidence
|
|
273
|
+
|
|
274
|
+
# TODO: Tweak this behaviour as we have a better idea how it should behave.
|
|
275
|
+
# Always outputs a priority in the range [0, 1]
|
|
276
|
+
self.priority = modifier * (self.severity ** (2 - modifier))
|
|
277
|
+
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class QueryEntry(BaseModel):
|
|
282
|
+
classification: str = Field(
|
|
283
|
+
description="Classification of results by the enrichment",
|
|
284
|
+
default="TLP:CLEAR",
|
|
285
|
+
examples=sample(sorted(CLASSIFICATION.list_all_classification_combinations()), k=5),
|
|
286
|
+
)
|
|
287
|
+
count: int = Field(
|
|
288
|
+
description="Number of matches from the search",
|
|
289
|
+
default=1,
|
|
290
|
+
examples=sorted([floor(i / 10) for i in randbytes(5)]), # noqa: S311
|
|
291
|
+
)
|
|
292
|
+
link: Optional[Url] = Field(
|
|
293
|
+
description="Link to more information", default=None, examples=[Url("https://example.com/moreinfo"), None]
|
|
294
|
+
)
|
|
295
|
+
annotations: list[Annotation] = Field(
|
|
296
|
+
description="A list of annotations returned from the service for this entry", default=[]
|
|
297
|
+
)
|
|
298
|
+
raw_data: Any = Field(
|
|
299
|
+
description="The raw records associated with the generated annotations.",
|
|
300
|
+
default=None,
|
|
301
|
+
examples=[{"id": 1, "raw_field": "some_data"}, [{"id": 1, "other_data": "example", "other_row": 45}]],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
305
|
+
|
|
306
|
+
@field_validator("classification")
|
|
307
|
+
@classmethod
|
|
308
|
+
def check_classification(cls, classification: str) -> str: # noqa: ANN102
|
|
309
|
+
"""Validates the provided classification.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
classification (str): The classification to validate.
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
AssertionError: Raised whenever the provided classification is not valid.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
str: The validated classification.
|
|
319
|
+
"""
|
|
320
|
+
return validate_classification(classification)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class QueryResult(BaseModel):
|
|
324
|
+
type: str = Field(
|
|
325
|
+
description="The type of the value represented by this result", examples=list(SUPPORTED_TYPES.keys())
|
|
326
|
+
)
|
|
327
|
+
value: str = Field(
|
|
328
|
+
description="The value represented by this result",
|
|
329
|
+
examples=["127.0.0.1", "email@example.com", hashlib.sha256("example".encode()).hexdigest()],
|
|
330
|
+
)
|
|
331
|
+
source: str = Field(description="The name of the plugin providing this result", examples=["example_plugin"])
|
|
332
|
+
error: Optional[str] = Field(
|
|
333
|
+
description="Error message returned by data source",
|
|
334
|
+
default=None,
|
|
335
|
+
examples=["An error occurred when enriching the data.", None],
|
|
336
|
+
)
|
|
337
|
+
items: list[QueryEntry] = Field(description="List of results from the source", default=[])
|
|
338
|
+
maintainer: Optional[str] = Field(
|
|
339
|
+
description="Email contact in the RFC-5322 format 'Full Name <email_address>'.",
|
|
340
|
+
default=None,
|
|
341
|
+
examples=["maintainer@example.com", None],
|
|
342
|
+
)
|
|
343
|
+
datahub_link: Optional[Url] = Field(
|
|
344
|
+
description="Link to datahub entry on this enrichment",
|
|
345
|
+
default=None,
|
|
346
|
+
examples=[Url("https://example.com/datahub"), None],
|
|
347
|
+
)
|
|
348
|
+
documentation_link: Optional[Url] = Field(
|
|
349
|
+
description="Link to documentation on this enrichment",
|
|
350
|
+
default=None,
|
|
351
|
+
examples=[Url("https://example.com/documentation"), None],
|
|
352
|
+
)
|
|
353
|
+
latency: float = Field(
|
|
354
|
+
description="Total duration (in milliseconds) taken to resolve this result",
|
|
355
|
+
default=0,
|
|
356
|
+
examples=sorted([i * 10 for i in randbytes(5)]), # noqa: S311
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
360
|
+
|
|
361
|
+
@field_validator("maintainer")
|
|
362
|
+
@classmethod
|
|
363
|
+
def validate_maintainer(cls, maintainer: Optional[str]) -> Optional[str]: # noqa: ANN102
|
|
364
|
+
"""Validates the maintainer field.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
maintainer (Optional[str]): The maintainer field to validate. If None, it will be passed through.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
AssertionError: Raised whenever the field is in an invalid format.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Optional[str]: The validated maintainer field.
|
|
374
|
+
"""
|
|
375
|
+
if maintainer:
|
|
376
|
+
parsed_addr = parseaddr(maintainer)
|
|
377
|
+
if not (all(parsed_addr) and "@" in parsed_addr[1]):
|
|
378
|
+
raise AssertionError("Maintainer string must be in RFC-5322 format.")
|
|
379
|
+
|
|
380
|
+
return maintainer
|
|
381
|
+
|
|
382
|
+
@field_validator("items")
|
|
383
|
+
@classmethod
|
|
384
|
+
def validate_items(cls, items: list[QueryEntry], info: ValidationInfo) -> list[QueryEntry]: # noqa: ANN102
|
|
385
|
+
"""Validate that if classification data was provided, all annotations match the user's classification.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
items (list[QueryEntry]): The items to validate.
|
|
389
|
+
info (ValidationInfo): Additional validation info.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
list[QueryEntry]: The validated items field.
|
|
393
|
+
"""
|
|
394
|
+
if info.context:
|
|
395
|
+
user_classification = info.context.get("user", {}).get("classification", None)
|
|
396
|
+
if user_classification:
|
|
397
|
+
filtered_results: list[QueryEntry] = []
|
|
398
|
+
|
|
399
|
+
for item in items:
|
|
400
|
+
if CLASSIFICATION.is_accessible(user_classification, item.classification):
|
|
401
|
+
filtered_results.append(item)
|
|
402
|
+
else:
|
|
403
|
+
logger.debug(
|
|
404
|
+
"Removing item at classification %s, inaccessible to user classification %s",
|
|
405
|
+
item.classification,
|
|
406
|
+
user_classification,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
if len(items) > len(filtered_results):
|
|
410
|
+
logger.info(
|
|
411
|
+
"Dropped %s items due to inaccessible classification (user classification: %s)",
|
|
412
|
+
len(items) - len(filtered_results),
|
|
413
|
+
user_classification,
|
|
414
|
+
)
|
|
415
|
+
elif DEBUG:
|
|
416
|
+
logger.debug(
|
|
417
|
+
"All %s values are accessible by user classification %s", len(items), user_classification
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return filtered_results
|
|
421
|
+
else:
|
|
422
|
+
logger.warning("No user classification given, classification parsing will not occur")
|
|
423
|
+
else:
|
|
424
|
+
logger.warning("No user context given, classification parsing will not occur")
|
|
425
|
+
|
|
426
|
+
return items
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class PluginResponse(BaseModel):
|
|
430
|
+
pass
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# ruff: noqa: D101
|
|
2
|
+
from typing import Any, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from clue.models.results.base import Result
|
|
7
|
+
from clue.models.results.graph import GraphResult
|
|
8
|
+
from clue.models.results.image import ImageResult
|
|
9
|
+
from clue.models.results.status import StatusResult
|
|
10
|
+
|
|
11
|
+
DATA = TypeVar("DATA", bound=(dict[str, Any] | list[dict[str, Any]] | str | Result))
|
|
12
|
+
|
|
13
|
+
FORMAT_MAPPINGS: dict[type[BaseModel] | type[dict] | type[list] | type[str], str] = {
|
|
14
|
+
dict: "json",
|
|
15
|
+
list: "json",
|
|
16
|
+
str: "markdown",
|
|
17
|
+
ImageResult: ImageResult.format(),
|
|
18
|
+
StatusResult: StatusResult.format(),
|
|
19
|
+
GraphResult: GraphResult.format(),
|
|
20
|
+
}
|
|
21
|
+
FORMAT_MAPPINGS_REVERSE: dict[str, list[type[BaseModel] | type[dict] | type[list] | type[str]]] = {}
|
|
22
|
+
|
|
23
|
+
for k, v in FORMAT_MAPPINGS.items():
|
|
24
|
+
if v not in FORMAT_MAPPINGS_REVERSE:
|
|
25
|
+
FORMAT_MAPPINGS_REVERSE[v] = [k]
|
|
26
|
+
else:
|
|
27
|
+
FORMAT_MAPPINGS_REVERSE[v].append(k)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_result(model: type[Result]):
|
|
31
|
+
"Add a new result type to the mappings"
|
|
32
|
+
if model.format() not in FORMAT_MAPPINGS_REVERSE:
|
|
33
|
+
FORMAT_MAPPINGS[model] = model.format()
|
|
34
|
+
FORMAT_MAPPINGS_REVERSE[model.format()] = [model]
|