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.
Files changed (91) hide show
  1. clue/.gitignore +21 -0
  2. clue/__init__.py +0 -0
  3. clue/api/__init__.py +211 -0
  4. clue/api/base.py +99 -0
  5. clue/api/v1/__init__.py +82 -0
  6. clue/api/v1/actions.py +92 -0
  7. clue/api/v1/auth.py +243 -0
  8. clue/api/v1/configs.py +83 -0
  9. clue/api/v1/fetchers.py +94 -0
  10. clue/api/v1/lookup.py +221 -0
  11. clue/api/v1/registration.py +109 -0
  12. clue/api/v1/static.py +94 -0
  13. clue/app.py +166 -0
  14. clue/cache/__init__.py +129 -0
  15. clue/common/__init__.py +0 -0
  16. clue/common/classification.py +1006 -0
  17. clue/common/classification.yml +130 -0
  18. clue/common/dict_utils.py +130 -0
  19. clue/common/exceptions.py +199 -0
  20. clue/common/forge.py +152 -0
  21. clue/common/json_utils.py +10 -0
  22. clue/common/list_utils.py +11 -0
  23. clue/common/logging/__init__.py +291 -0
  24. clue/common/logging/audit.py +157 -0
  25. clue/common/logging/format.py +42 -0
  26. clue/common/regex.py +31 -0
  27. clue/common/str_utils.py +213 -0
  28. clue/common/swagger.py +139 -0
  29. clue/common/uid.py +47 -0
  30. clue/config.py +60 -0
  31. clue/constants/__init__.py +0 -0
  32. clue/constants/supported_types.py +38 -0
  33. clue/cronjobs/__init__.py +30 -0
  34. clue/cronjobs/plugins.py +32 -0
  35. clue/error.py +129 -0
  36. clue/gunicorn_config.py +29 -0
  37. clue/healthz.py +74 -0
  38. clue/helper/discover.py +53 -0
  39. clue/helper/headers.py +30 -0
  40. clue/helper/oauth.py +128 -0
  41. clue/models/__init__.py +0 -0
  42. clue/models/actions.py +243 -0
  43. clue/models/config.py +456 -0
  44. clue/models/fetchers.py +136 -0
  45. clue/models/graph.py +162 -0
  46. clue/models/model_list.py +52 -0
  47. clue/models/network.py +430 -0
  48. clue/models/results/__init__.py +34 -0
  49. clue/models/results/base.py +10 -0
  50. clue/models/results/graph.py +26 -0
  51. clue/models/results/image.py +22 -0
  52. clue/models/results/status.py +55 -0
  53. clue/models/results/validation.py +57 -0
  54. clue/models/selector.py +67 -0
  55. clue/models/utils.py +52 -0
  56. clue/models/validators.py +19 -0
  57. clue/patched.py +8 -0
  58. clue/plugin/__init__.py +1008 -0
  59. clue/plugin/helpers/__init__.py +0 -0
  60. clue/plugin/helpers/central_server.py +27 -0
  61. clue/plugin/helpers/email_render.py +228 -0
  62. clue/plugin/helpers/token.py +34 -0
  63. clue/plugin/helpers/trino.py +103 -0
  64. clue/plugin/interactive.py +270 -0
  65. clue/plugin/models.py +19 -0
  66. clue/plugin/utils.py +78 -0
  67. clue/remote/__init__.py +0 -0
  68. clue/remote/datatypes/__init__.py +130 -0
  69. clue/remote/datatypes/cache.py +62 -0
  70. clue/remote/datatypes/events.py +118 -0
  71. clue/remote/datatypes/hash.py +193 -0
  72. clue/remote/datatypes/queues/__init__.py +0 -0
  73. clue/remote/datatypes/queues/comms.py +62 -0
  74. clue/remote/datatypes/set.py +96 -0
  75. clue/remote/datatypes/user_quota_tracker.py +54 -0
  76. clue/security/__init__.py +211 -0
  77. clue/security/obo.py +95 -0
  78. clue/security/utils.py +34 -0
  79. clue/services/action_service.py +186 -0
  80. clue/services/auth_service.py +348 -0
  81. clue/services/config_service.py +38 -0
  82. clue/services/fetcher_service.py +203 -0
  83. clue/services/jwt_service.py +233 -0
  84. clue/services/lookup_service.py +786 -0
  85. clue/services/type_service.py +165 -0
  86. clue/services/user_service.py +152 -0
  87. clue_api-1.0.0.dev7.dist-info/METADATA +111 -0
  88. clue_api-1.0.0.dev7.dist-info/RECORD +91 -0
  89. clue_api-1.0.0.dev7.dist-info/WHEEL +4 -0
  90. clue_api-1.0.0.dev7.dist-info/entry_points.txt +8 -0
  91. 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]
@@ -0,0 +1,10 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Result(BaseModel):
5
+ "Base result object"
6
+
7
+ @staticmethod
8
+ def format() -> str:
9
+ "Return the format of the result"
10
+ raise NotImplementedError()