seizu-cli 2.0.0__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.
- seizu_cli/__init__.py +0 -0
- seizu_cli/__main__.py +4 -0
- seizu_cli/auth.py +365 -0
- seizu_cli/client.py +62 -0
- seizu_cli/commands/__init__.py +0 -0
- seizu_cli/commands/auth.py +84 -0
- seizu_cli/commands/reports.py +223 -0
- seizu_cli/commands/scheduled_queries.py +157 -0
- seizu_cli/commands/seed.py +852 -0
- seizu_cli/commands/skillsets.py +371 -0
- seizu_cli/commands/toolsets.py +506 -0
- seizu_cli/config.py +38 -0
- seizu_cli/main.py +181 -0
- seizu_cli/schema.py +31 -0
- seizu_cli/state.py +29 -0
- seizu_cli-2.0.0.dist-info/METADATA +13 -0
- seizu_cli-2.0.0.dist-info/RECORD +22 -0
- seizu_cli-2.0.0.dist-info/WHEEL +5 -0
- seizu_cli-2.0.0.dist-info/entry_points.txt +2 -0
- seizu_cli-2.0.0.dist-info/top_level.txt +2 -0
- seizu_schema/__init__.py +1 -0
- seizu_schema/reporting_config.py +939 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
"""Pydantic models for the Seizu reporting YAML config format.
|
|
2
|
+
|
|
3
|
+
This module is the single authoritative source for the reporting dashboard
|
|
4
|
+
configuration schema. It is used by both the ``reporting`` backend and the
|
|
5
|
+
``seizu_cli`` CLI; neither package defines these models independently.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
LOWER_SNAKE_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_lower_snake_id(value: str) -> str:
|
|
18
|
+
if not LOWER_SNAKE_ID_RE.fullmatch(value):
|
|
19
|
+
raise ValueError("must be lower_snake_case matching ^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InputDefault(BaseModel):
|
|
24
|
+
label: str = Field(
|
|
25
|
+
description="The label for the default.",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
value: str = Field(
|
|
29
|
+
description="The value for the default.",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Input(BaseModel):
|
|
34
|
+
input_id: str = Field(
|
|
35
|
+
description="Reference to the query in the inputs section.",
|
|
36
|
+
examples=["cve_base_severity"],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
label: str = Field(
|
|
40
|
+
description="The label to use for the select element.",
|
|
41
|
+
examples=["CVE base severity"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
type: Literal["autocomplete", "text"] = Field(
|
|
45
|
+
description="The type of input to use.",
|
|
46
|
+
examples=["autocomplete"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
cypher: str | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
description="The Cypher query to execute. Must return ``value``.",
|
|
52
|
+
examples=[
|
|
53
|
+
"""
|
|
54
|
+
.. code-block:: cypher
|
|
55
|
+
|
|
56
|
+
MATCH (c:CVE)
|
|
57
|
+
RETURN DISTINCT c.base_severity AS value
|
|
58
|
+
"""
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
default: InputDefault | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
description="The default value to set if no value is selected.",
|
|
65
|
+
examples=[
|
|
66
|
+
"""
|
|
67
|
+
.. code-block:: yaml
|
|
68
|
+
|
|
69
|
+
label: (all)
|
|
70
|
+
value: .*
|
|
71
|
+
"""
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
size: int | None = Field(
|
|
76
|
+
default=2,
|
|
77
|
+
description="The size of the input element.",
|
|
78
|
+
examples=["2"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BarPanelSettings(BaseModel):
|
|
83
|
+
legend: str | None = Field(
|
|
84
|
+
default=None,
|
|
85
|
+
description=("The type of legend to use; ``row`` or ``column``. If unset, then no legend will be used."),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PiePanelSettings(BaseModel):
|
|
90
|
+
legend: str | None = Field(
|
|
91
|
+
default=None,
|
|
92
|
+
description=(
|
|
93
|
+
"The type of legend to use; ``row`` or ``column``. If unset,"
|
|
94
|
+
" then no legend will be used, and arc labels will be used"
|
|
95
|
+
" instead."
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class GraphPanelSettings(BaseModel):
|
|
101
|
+
node_label: str | None = Field(
|
|
102
|
+
default=None,
|
|
103
|
+
description=("The node property to display as the node label. Defaults to ``label`` if unset."),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
node_color_by: str | None = Field(
|
|
107
|
+
default=None,
|
|
108
|
+
description=("The node property to use for color grouping. Defaults to ``group`` if unset."),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ProgressPanelSettings(BaseModel):
|
|
113
|
+
show_label: bool | None = Field(
|
|
114
|
+
default=None,
|
|
115
|
+
description=(
|
|
116
|
+
"Whether to render the ``numerator / denominator`` label above"
|
|
117
|
+
" the progress wheel. Defaults to ``True`` when unset; pass"
|
|
118
|
+
" ``False`` to hide the label and show only the wheel and"
|
|
119
|
+
" percentage."
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PanelThreshold(BaseModel):
|
|
125
|
+
value: float = Field(
|
|
126
|
+
description=(
|
|
127
|
+
"The threshold trigger value. For ``count`` panels, the metric"
|
|
128
|
+
" is the raw total. For ``progress`` panels, the metric is the"
|
|
129
|
+
" completion percentage (0-100)."
|
|
130
|
+
),
|
|
131
|
+
examples=[70],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
color: str = Field(
|
|
135
|
+
description=(
|
|
136
|
+
"The color to apply when the metric matches this threshold. A CSS color string (e.g. ``#F44336``, ``red``)."
|
|
137
|
+
),
|
|
138
|
+
examples=["#F44336"],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class PanelParam(BaseModel):
|
|
143
|
+
name: str = Field(
|
|
144
|
+
description="The parameter name to use when passing this input into the query.",
|
|
145
|
+
examples=["severity"],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
input_id: str | None = Field(
|
|
149
|
+
default=None,
|
|
150
|
+
description="Reference to the query in the inputs section.",
|
|
151
|
+
examples=["cve_base_severity"],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
value: Any | None = Field(
|
|
155
|
+
default=None,
|
|
156
|
+
description="The parameter value to pass into the query.",
|
|
157
|
+
examples=[
|
|
158
|
+
"""
|
|
159
|
+
.. code-block:: yaml
|
|
160
|
+
|
|
161
|
+
params:
|
|
162
|
+
- name: integrityImpact
|
|
163
|
+
value: HIGH
|
|
164
|
+
"""
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Panel(BaseModel):
|
|
170
|
+
type: Literal[
|
|
171
|
+
"table",
|
|
172
|
+
"vertical-table",
|
|
173
|
+
"count",
|
|
174
|
+
"bar",
|
|
175
|
+
"pie",
|
|
176
|
+
"graph",
|
|
177
|
+
"progress",
|
|
178
|
+
"markdown",
|
|
179
|
+
] = Field(
|
|
180
|
+
description="The type of panel to use.",
|
|
181
|
+
examples=["table"],
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@model_validator(mode="before")
|
|
185
|
+
@classmethod
|
|
186
|
+
def reject_legacy_size(cls, data: Any) -> Any:
|
|
187
|
+
if isinstance(data, dict) and "size" in data:
|
|
188
|
+
raise ValueError("Panel field 'size' is no longer supported; use 'w' for width.")
|
|
189
|
+
return data
|
|
190
|
+
|
|
191
|
+
cypher: str | None = Field(
|
|
192
|
+
default=None,
|
|
193
|
+
description="A reference to a cypher from the cypher section of the configuration.",
|
|
194
|
+
examples=["cves"],
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
details_cypher: str | None = Field(
|
|
198
|
+
default=None,
|
|
199
|
+
description=(
|
|
200
|
+
"A reference to a cypher from the cypher section of the configuration."
|
|
201
|
+
" Used in the details section of the panel, as a table."
|
|
202
|
+
" The query can return rows in any of the formats supported by the"
|
|
203
|
+
" ``table`` panel type."
|
|
204
|
+
),
|
|
205
|
+
examples=["cves-details"],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
params: list[PanelParam] = Field(
|
|
209
|
+
default_factory=list,
|
|
210
|
+
description=(
|
|
211
|
+
"A list of parameters to send into the query. The parameters can"
|
|
212
|
+
" directly have values, or can be a reference to an input."
|
|
213
|
+
),
|
|
214
|
+
examples=[
|
|
215
|
+
"""
|
|
216
|
+
.. code-block:: yaml
|
|
217
|
+
|
|
218
|
+
params:
|
|
219
|
+
- name: severity
|
|
220
|
+
input_id: cve_base_severity
|
|
221
|
+
- name: integrityImpact
|
|
222
|
+
value: HIGH
|
|
223
|
+
"""
|
|
224
|
+
],
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
caption: str | None = Field(
|
|
228
|
+
default=None,
|
|
229
|
+
description="The caption to use for the panel.",
|
|
230
|
+
examples=["Critical CVEs"],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
hide_caption: bool | None = Field(
|
|
234
|
+
default=None,
|
|
235
|
+
description="When True, the panel caption is not rendered in view mode.",
|
|
236
|
+
examples=["true"],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
table_id: str | None = Field(
|
|
240
|
+
default=None,
|
|
241
|
+
description=(
|
|
242
|
+
"The cypher attribute to use for the table's unique ID, if using a"
|
|
243
|
+
" type of table or vertical-table. If not set, a random ID will be"
|
|
244
|
+
" generated. A table_id should be set for ``vertical-table``, or"
|
|
245
|
+
" the panel will have a random ID used as the caption."
|
|
246
|
+
),
|
|
247
|
+
examples=["cve_id"],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
markdown: str | None = Field(
|
|
251
|
+
default=None,
|
|
252
|
+
description=("The markdown to use for the panel. Only used for type ``markdown``."),
|
|
253
|
+
examples=[
|
|
254
|
+
"""
|
|
255
|
+
.. code-block:: markdown
|
|
256
|
+
|
|
257
|
+
## Affects
|
|
258
|
+
|
|
259
|
+
Versions x.x.x - x.x.x
|
|
260
|
+
|
|
261
|
+
## Recommended action
|
|
262
|
+
|
|
263
|
+
Upgrade to the latest version of the software.
|
|
264
|
+
"""
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
w: int | None = Field(
|
|
269
|
+
default=None,
|
|
270
|
+
description="The width of the panel in grid columns (1-12). Used by react-grid-layout.",
|
|
271
|
+
examples=["3"],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
h: int | None = Field(
|
|
275
|
+
default=None,
|
|
276
|
+
description=(
|
|
277
|
+
"The height of the panel in grid row units (each row is 48px)."
|
|
278
|
+
" When unset, the frontend derives a sensible default per panel"
|
|
279
|
+
" type."
|
|
280
|
+
),
|
|
281
|
+
examples=["8"],
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
x: int | None = Field(
|
|
285
|
+
default=None,
|
|
286
|
+
description=(
|
|
287
|
+
"The column index (0-based) within the row's grid. When unset,"
|
|
288
|
+
" the frontend packs panels left-to-right automatically."
|
|
289
|
+
),
|
|
290
|
+
examples=["0"],
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
y: int | None = Field(
|
|
294
|
+
default=None,
|
|
295
|
+
description=(
|
|
296
|
+
"The row index within the row's grid. Always 0 for single-row layouts; reserved for future multi-row use."
|
|
297
|
+
),
|
|
298
|
+
examples=["0"],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
min_h: int | None = Field(
|
|
302
|
+
default=None,
|
|
303
|
+
description=(
|
|
304
|
+
"Optional minimum height in grid row units. Enforced by react-grid-layout when the user resizes the panel."
|
|
305
|
+
),
|
|
306
|
+
examples=["4"],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
auto_height: bool | None = Field(
|
|
310
|
+
default=None,
|
|
311
|
+
description=(
|
|
312
|
+
"When ``True``, the panel renders at its natural content height"
|
|
313
|
+
" instead of filling its grid cell. Only meaningful for"
|
|
314
|
+
" ``markdown`` and ``vertical-table`` panel types; ignored"
|
|
315
|
+
" elsewhere."
|
|
316
|
+
),
|
|
317
|
+
examples=["true"],
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
threshold: float | None = Field(
|
|
321
|
+
default=None,
|
|
322
|
+
description=(
|
|
323
|
+
"(Legacy) Single threshold value for ``count`` / ``progress``"
|
|
324
|
+
" panels. New configs should use ``thresholds``; this field is"
|
|
325
|
+
" retained as a fallback when ``thresholds`` is unset."
|
|
326
|
+
),
|
|
327
|
+
examples=["70"],
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
thresholds: list[PanelThreshold] | None = Field(
|
|
331
|
+
default=None,
|
|
332
|
+
description=(
|
|
333
|
+
"Ordered list of ``{value, color}`` pairs for ``count`` and"
|
|
334
|
+
" ``progress`` panels. The applicable color is the color of the"
|
|
335
|
+
" threshold with the highest ``value`` that is less than or"
|
|
336
|
+
" equal to the metric (raw total for ``count``, completion"
|
|
337
|
+
" percentage for ``progress``). When unset, falls back to the"
|
|
338
|
+
" legacy ``threshold`` field."
|
|
339
|
+
),
|
|
340
|
+
examples=[
|
|
341
|
+
"""
|
|
342
|
+
.. code-block:: yaml
|
|
343
|
+
|
|
344
|
+
thresholds:
|
|
345
|
+
- value: 0
|
|
346
|
+
color: "#F44336"
|
|
347
|
+
- value: 70
|
|
348
|
+
color: "#4CAF50"
|
|
349
|
+
"""
|
|
350
|
+
],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
bar_settings: BarPanelSettings | None = Field(
|
|
354
|
+
default=None,
|
|
355
|
+
description="Settings specific to bar panels.",
|
|
356
|
+
examples=[
|
|
357
|
+
"""
|
|
358
|
+
.. code-block:: yaml
|
|
359
|
+
|
|
360
|
+
bar_settings:
|
|
361
|
+
legend: column
|
|
362
|
+
"""
|
|
363
|
+
],
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
pie_settings: PiePanelSettings | None = Field(
|
|
367
|
+
default=None,
|
|
368
|
+
description="Settings specific to pie panels.",
|
|
369
|
+
examples=[
|
|
370
|
+
"""
|
|
371
|
+
.. code-block:: yaml
|
|
372
|
+
|
|
373
|
+
pie_settings:
|
|
374
|
+
legend: column
|
|
375
|
+
"""
|
|
376
|
+
],
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
graph_settings: GraphPanelSettings | None = Field(
|
|
380
|
+
default=None,
|
|
381
|
+
description="Settings specific to graph panels.",
|
|
382
|
+
examples=[
|
|
383
|
+
"""
|
|
384
|
+
.. code-block:: yaml
|
|
385
|
+
|
|
386
|
+
graph_settings:
|
|
387
|
+
node_label: label
|
|
388
|
+
node_color_by: group
|
|
389
|
+
"""
|
|
390
|
+
],
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
progress_settings: ProgressPanelSettings | None = Field(
|
|
394
|
+
default=None,
|
|
395
|
+
description="Settings specific to progress panels.",
|
|
396
|
+
examples=[
|
|
397
|
+
"""
|
|
398
|
+
.. code-block:: yaml
|
|
399
|
+
|
|
400
|
+
progress_settings:
|
|
401
|
+
show_label: false
|
|
402
|
+
"""
|
|
403
|
+
],
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class Row(BaseModel):
|
|
408
|
+
name: str = Field(
|
|
409
|
+
description="The name of the row; shown as title above the row.",
|
|
410
|
+
examples=["CVEs"],
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
hide_header: bool | None = Field(
|
|
414
|
+
default=None,
|
|
415
|
+
description="When True, the row name header is not rendered in view mode.",
|
|
416
|
+
examples=["true"],
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
collapsible: bool | None = Field(
|
|
420
|
+
default=None,
|
|
421
|
+
description="When True, the row can be collapsed by clicking the header in view mode.",
|
|
422
|
+
examples=["true"],
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
panels: list[Panel] = Field(
|
|
426
|
+
description="The panels to show in the row.",
|
|
427
|
+
examples=[
|
|
428
|
+
"""
|
|
429
|
+
.. code-block:: yaml
|
|
430
|
+
|
|
431
|
+
panels:
|
|
432
|
+
- cypher: cves-by-severity
|
|
433
|
+
details_cypher: cves-by-severity-details
|
|
434
|
+
type: count
|
|
435
|
+
params:
|
|
436
|
+
base_severity: CRITICAL
|
|
437
|
+
caption: Critical CVEs
|
|
438
|
+
w: 2
|
|
439
|
+
"""
|
|
440
|
+
],
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class Report(BaseModel):
|
|
445
|
+
schema_version: int = Field(
|
|
446
|
+
default=1,
|
|
447
|
+
description=(
|
|
448
|
+
"The schema version of the report config. Increment when making breaking changes to the report schema."
|
|
449
|
+
),
|
|
450
|
+
examples=[1],
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
pinned: bool | None = Field(
|
|
454
|
+
default=None,
|
|
455
|
+
description=(
|
|
456
|
+
"Optional seed metadata for dashboard navigation. When set, the"
|
|
457
|
+
" seeder pins or unpins the report after creating or updating it."
|
|
458
|
+
" This field is not stored inside report version configs."
|
|
459
|
+
),
|
|
460
|
+
examples=[True],
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
name: str = Field(
|
|
464
|
+
description="The name of the report.",
|
|
465
|
+
examples=["CVEs"],
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
queries: dict[str, str] = Field(
|
|
469
|
+
default_factory=dict,
|
|
470
|
+
description=(
|
|
471
|
+
"Named Cypher queries for this report."
|
|
472
|
+
" Panel ``cypher`` fields may reference a key from this dict,"
|
|
473
|
+
" or provide a Cypher query string directly."
|
|
474
|
+
),
|
|
475
|
+
examples=[
|
|
476
|
+
"""
|
|
477
|
+
.. code-block:: yaml
|
|
478
|
+
|
|
479
|
+
queries:
|
|
480
|
+
cves-total: |-
|
|
481
|
+
MATCH (c:CVE)
|
|
482
|
+
RETURN count(c.id) AS total
|
|
483
|
+
"""
|
|
484
|
+
],
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
inputs: list[Input] = Field(
|
|
488
|
+
default_factory=list,
|
|
489
|
+
description="The inputs to use for the report.",
|
|
490
|
+
examples=[
|
|
491
|
+
"""
|
|
492
|
+
.. code-block:: yaml
|
|
493
|
+
|
|
494
|
+
inputs:
|
|
495
|
+
- input_id: cve_base_severity
|
|
496
|
+
cypher: |-
|
|
497
|
+
MATCH (c:CVE)
|
|
498
|
+
RETURN c.base_severity AS base_severity
|
|
499
|
+
default:
|
|
500
|
+
label: (all)
|
|
501
|
+
value: .*
|
|
502
|
+
label: Base Severity
|
|
503
|
+
type: autocomplete
|
|
504
|
+
size: 2
|
|
505
|
+
"""
|
|
506
|
+
],
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
rows: list[Row] = Field(
|
|
510
|
+
default_factory=list,
|
|
511
|
+
description="The rows of the report.",
|
|
512
|
+
examples=[
|
|
513
|
+
"""
|
|
514
|
+
.. code-block:: yaml
|
|
515
|
+
|
|
516
|
+
rows:
|
|
517
|
+
- name: "CVEs"
|
|
518
|
+
panels:
|
|
519
|
+
- cypher: cves
|
|
520
|
+
type: table
|
|
521
|
+
params:
|
|
522
|
+
- name: severity
|
|
523
|
+
input_id: cve_base_severity
|
|
524
|
+
w: 12
|
|
525
|
+
"""
|
|
526
|
+
],
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class ScheduledQueryWatchScan(BaseModel):
|
|
531
|
+
grouptype: str | None = Field(
|
|
532
|
+
default=".*",
|
|
533
|
+
description=(
|
|
534
|
+
"Match against the grouptype attribute of the SyncMetadata"
|
|
535
|
+
" node, as a regex. If not set, the query will match against"
|
|
536
|
+
" ``.*``."
|
|
537
|
+
),
|
|
538
|
+
examples=["CVE"],
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
syncedtype: str | None = Field(
|
|
542
|
+
default=".*",
|
|
543
|
+
description=(
|
|
544
|
+
"Match against the syncedtype attribute of the SyncMetadata"
|
|
545
|
+
" node, as a regex. If not set, the query will match against"
|
|
546
|
+
" ``.*``."
|
|
547
|
+
),
|
|
548
|
+
examples=["year"],
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
groupid: str | None = Field(
|
|
552
|
+
default=".*",
|
|
553
|
+
description=(
|
|
554
|
+
"Match against the groupid attribute of the SyncMetadata"
|
|
555
|
+
" node, as a regex. If not set, the query will match against"
|
|
556
|
+
" ``.*``."
|
|
557
|
+
),
|
|
558
|
+
examples=["2019"],
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class ScheduledQueryAction(BaseModel):
|
|
563
|
+
action_type: str = Field(
|
|
564
|
+
description="The type of action to perform.",
|
|
565
|
+
examples=["slack", "sqs"],
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
action_config: dict[str, Any] = Field(
|
|
569
|
+
description=(
|
|
570
|
+
"The configuration for the action. See the documentation for the"
|
|
571
|
+
" relevant scheduled query module for information about the"
|
|
572
|
+
" configuration needed for each action type."
|
|
573
|
+
),
|
|
574
|
+
examples=[
|
|
575
|
+
"""
|
|
576
|
+
.. code-block:: yaml
|
|
577
|
+
|
|
578
|
+
action_config:
|
|
579
|
+
webhook_url: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
|
|
580
|
+
channel: #cve
|
|
581
|
+
username: CVE
|
|
582
|
+
icon_emoji: :cve:
|
|
583
|
+
"""
|
|
584
|
+
],
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class ScheduledQueryParam(BaseModel):
|
|
589
|
+
name: str = Field(
|
|
590
|
+
description="The parameter name to use when passing this input into the query.",
|
|
591
|
+
examples=["severity"],
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
value: Any = Field(
|
|
595
|
+
description="The parameter value to pass into the query.",
|
|
596
|
+
examples=[
|
|
597
|
+
"""
|
|
598
|
+
.. code-block:: yaml
|
|
599
|
+
|
|
600
|
+
params:
|
|
601
|
+
- name: integrityImpact
|
|
602
|
+
value: HIGH
|
|
603
|
+
"""
|
|
604
|
+
],
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class ScheduledQuery(BaseModel):
|
|
609
|
+
name: str = Field(
|
|
610
|
+
description="The name of the scheduled query.",
|
|
611
|
+
examples=["Recently published HIGH/CRITICAL CVEs"],
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
cypher: str = Field(
|
|
615
|
+
description="The cypher to use for the scheduled query.",
|
|
616
|
+
examples=["recent-cves"],
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
params: list[ScheduledQueryParam] = Field(
|
|
620
|
+
default_factory=list,
|
|
621
|
+
description=(
|
|
622
|
+
"A dictionary of parameters to pass to the cypher query. The keys"
|
|
623
|
+
" are the variable names, and the values are the values to pass."
|
|
624
|
+
),
|
|
625
|
+
examples=[
|
|
626
|
+
"""
|
|
627
|
+
.. code-block:: yaml
|
|
628
|
+
|
|
629
|
+
params:
|
|
630
|
+
- name: syncedtype
|
|
631
|
+
value:
|
|
632
|
+
- recent
|
|
633
|
+
- name: base_severity
|
|
634
|
+
value:
|
|
635
|
+
- HIGH
|
|
636
|
+
- CRITICAL
|
|
637
|
+
"""
|
|
638
|
+
],
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
frequency: int | None = Field(
|
|
642
|
+
default=None,
|
|
643
|
+
description=("The frequency of the scheduled query in minutes. Mutually exclusive with ``watch_scans``."),
|
|
644
|
+
examples=["1440"],
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
watch_scans: list[ScheduledQueryWatchScan] = Field(
|
|
648
|
+
default_factory=list,
|
|
649
|
+
description=(
|
|
650
|
+
"The scans to watch for the scheduled query. Based on"
|
|
651
|
+
" SyncMetadata. Query will triger if any of the watched"
|
|
652
|
+
" scans listed are updated. Mutually exclusive with"
|
|
653
|
+
" ``frequency``."
|
|
654
|
+
),
|
|
655
|
+
examples=[
|
|
656
|
+
"""
|
|
657
|
+
.. code-block:: yaml
|
|
658
|
+
|
|
659
|
+
watch_scans:
|
|
660
|
+
- grouptype: CVE
|
|
661
|
+
syncedtype: recent
|
|
662
|
+
- grouptype: CVE
|
|
663
|
+
syncedtype: modified
|
|
664
|
+
"""
|
|
665
|
+
],
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
enabled: bool | None = Field(
|
|
669
|
+
default=True,
|
|
670
|
+
description=("Whether the scheduled query should be enabled. If not set, the scheduled query will be enabled."),
|
|
671
|
+
examples=["true"],
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
actions: list[ScheduledQueryAction] = Field(
|
|
675
|
+
default_factory=list,
|
|
676
|
+
description=("The actions to perform when the scheduled query is triggered."),
|
|
677
|
+
examples=[
|
|
678
|
+
"""
|
|
679
|
+
.. code-block:: yaml
|
|
680
|
+
|
|
681
|
+
actions:
|
|
682
|
+
- action_type: slack
|
|
683
|
+
title: Recently published HIGH/CRITICAL CVEs
|
|
684
|
+
initial_comment: |
|
|
685
|
+
The following HIGH/CRITICAL CVEs have been published in the last 24 hours.
|
|
686
|
+
channels:
|
|
687
|
+
- C0000000000
|
|
688
|
+
"""
|
|
689
|
+
],
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
class ToolParamDef(BaseModel):
|
|
694
|
+
"""Definition of a single parameter accepted by a tool."""
|
|
695
|
+
|
|
696
|
+
name: str
|
|
697
|
+
type: Literal["string", "integer", "float", "boolean"]
|
|
698
|
+
description: str = ""
|
|
699
|
+
required: bool = True
|
|
700
|
+
default: Any | None = None
|
|
701
|
+
|
|
702
|
+
@field_validator("name")
|
|
703
|
+
@classmethod
|
|
704
|
+
def validate_name(cls, v: str) -> str:
|
|
705
|
+
return validate_lower_snake_id(v)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class ToolDef(BaseModel):
|
|
709
|
+
"""A tool definition within a toolset."""
|
|
710
|
+
|
|
711
|
+
name: str
|
|
712
|
+
description: str = ""
|
|
713
|
+
cypher: str
|
|
714
|
+
parameters: list[ToolParamDef] = Field(default_factory=list)
|
|
715
|
+
enabled: bool = True
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class ToolsetDef(BaseModel):
|
|
719
|
+
"""A toolset definition for MCP tool exposure."""
|
|
720
|
+
|
|
721
|
+
name: str
|
|
722
|
+
description: str = ""
|
|
723
|
+
enabled: bool = True
|
|
724
|
+
tools: dict[str, ToolDef] = Field(default_factory=dict)
|
|
725
|
+
|
|
726
|
+
@field_validator("tools")
|
|
727
|
+
@classmethod
|
|
728
|
+
def validate_tool_ids(cls, v: dict[str, ToolDef]) -> dict[str, ToolDef]:
|
|
729
|
+
for key in v:
|
|
730
|
+
validate_lower_snake_id(key)
|
|
731
|
+
return v
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class SkillDef(BaseModel):
|
|
735
|
+
"""A prompt template definition within a skillset."""
|
|
736
|
+
|
|
737
|
+
name: str
|
|
738
|
+
description: str = ""
|
|
739
|
+
template: str
|
|
740
|
+
parameters: list[ToolParamDef] = Field(default_factory=list)
|
|
741
|
+
triggers: list[str] = Field(default_factory=list)
|
|
742
|
+
tools_required: list[str] = Field(default_factory=list)
|
|
743
|
+
enabled: bool = True
|
|
744
|
+
|
|
745
|
+
@field_validator("triggers")
|
|
746
|
+
@classmethod
|
|
747
|
+
def validate_triggers(cls, v: list[str]) -> list[str]:
|
|
748
|
+
seen: set[str] = set()
|
|
749
|
+
for value in v:
|
|
750
|
+
if not value.strip():
|
|
751
|
+
raise ValueError("triggers entries must not be empty")
|
|
752
|
+
if value in seen:
|
|
753
|
+
raise ValueError("triggers entries must be unique")
|
|
754
|
+
seen.add(value)
|
|
755
|
+
return v
|
|
756
|
+
|
|
757
|
+
@field_validator("tools_required")
|
|
758
|
+
@classmethod
|
|
759
|
+
def validate_tools_required(cls, v: list[str]) -> list[str]:
|
|
760
|
+
seen: set[str] = set()
|
|
761
|
+
for value in v:
|
|
762
|
+
if not re.fullmatch(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*__[a-z][a-z0-9]*(?:_[a-z0-9]+)*$", value):
|
|
763
|
+
raise ValueError("tools_required entries must use MCP tool names like toolset_id__tool_id")
|
|
764
|
+
if value in seen:
|
|
765
|
+
raise ValueError("tools_required entries must be unique")
|
|
766
|
+
seen.add(value)
|
|
767
|
+
return v
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class SkillsetDef(BaseModel):
|
|
771
|
+
"""A skillset definition for MCP prompt exposure."""
|
|
772
|
+
|
|
773
|
+
name: str
|
|
774
|
+
description: str = ""
|
|
775
|
+
enabled: bool = True
|
|
776
|
+
skills: dict[str, SkillDef] = Field(default_factory=dict)
|
|
777
|
+
|
|
778
|
+
@field_validator("skills")
|
|
779
|
+
@classmethod
|
|
780
|
+
def validate_skill_ids(cls, v: dict[str, SkillDef]) -> dict[str, SkillDef]:
|
|
781
|
+
for key in v:
|
|
782
|
+
validate_lower_snake_id(key)
|
|
783
|
+
return v
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class ReportingConfig(BaseModel):
|
|
787
|
+
queries: dict[str, str] = Field(
|
|
788
|
+
default_factory=dict,
|
|
789
|
+
description="The queries to use for the report.",
|
|
790
|
+
examples=[
|
|
791
|
+
"""
|
|
792
|
+
.. code-block:: yaml
|
|
793
|
+
|
|
794
|
+
queries:
|
|
795
|
+
cves-severity-of-total: |-
|
|
796
|
+
MATCH (c:CVE)
|
|
797
|
+
WITH COUNT(DISTINCT c.id) AS denominator
|
|
798
|
+
MATCH (c:CVE)
|
|
799
|
+
WHERE c.base_severity = "CRITICAL"
|
|
800
|
+
RETURN count(DISTINCT c.id) AS numerator, denominator
|
|
801
|
+
"""
|
|
802
|
+
],
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
dashboard: str | None = Field(
|
|
806
|
+
default=None,
|
|
807
|
+
description=(
|
|
808
|
+
"Key of the report in the ``reports`` section to use as the default"
|
|
809
|
+
" dashboard. If unset, no report is displayed on the dashboard page."
|
|
810
|
+
),
|
|
811
|
+
examples=["dashboard"],
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
reports: dict[str, Report] = Field(
|
|
815
|
+
default_factory=dict,
|
|
816
|
+
description="The reports to use for the report.",
|
|
817
|
+
examples=[
|
|
818
|
+
"""
|
|
819
|
+
.. code-block:: yaml
|
|
820
|
+
|
|
821
|
+
reports:
|
|
822
|
+
cves:
|
|
823
|
+
name: CVEs
|
|
824
|
+
rows:
|
|
825
|
+
- name: CVEs
|
|
826
|
+
panels:
|
|
827
|
+
- cypher: cves
|
|
828
|
+
type: table
|
|
829
|
+
w: 12
|
|
830
|
+
"""
|
|
831
|
+
],
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
scheduled_queries: list[ScheduledQuery] = Field(
|
|
835
|
+
default_factory=list,
|
|
836
|
+
description="The scheduled queries to run.",
|
|
837
|
+
examples=[
|
|
838
|
+
"""
|
|
839
|
+
.. code-block:: yaml
|
|
840
|
+
|
|
841
|
+
scheduled_queries:
|
|
842
|
+
- name: CVEs by severity
|
|
843
|
+
cypher: recent-cves
|
|
844
|
+
frequency: 1440
|
|
845
|
+
actions:
|
|
846
|
+
- action_type: slack
|
|
847
|
+
action_config:
|
|
848
|
+
title: Recently published HIGH/CRITICAL CVEs
|
|
849
|
+
"""
|
|
850
|
+
],
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
toolsets: dict[str, ToolsetDef] = Field(
|
|
854
|
+
default_factory=dict,
|
|
855
|
+
description="Toolset definitions for MCP tool exposure.",
|
|
856
|
+
examples=[
|
|
857
|
+
"""
|
|
858
|
+
.. code-block:: yaml
|
|
859
|
+
|
|
860
|
+
toolsets:
|
|
861
|
+
my-toolset:
|
|
862
|
+
name: My Toolset
|
|
863
|
+
description: A collection of graph tools
|
|
864
|
+
enabled: true
|
|
865
|
+
tools:
|
|
866
|
+
my-tool:
|
|
867
|
+
name: My Tool
|
|
868
|
+
description: Counts nodes
|
|
869
|
+
cypher: MATCH (n) RETURN count(n) AS total
|
|
870
|
+
enabled: true
|
|
871
|
+
"""
|
|
872
|
+
],
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
skillsets: dict[str, SkillsetDef] = Field(
|
|
876
|
+
default_factory=dict,
|
|
877
|
+
description="Skillset definitions for MCP prompt exposure.",
|
|
878
|
+
examples=[
|
|
879
|
+
"""
|
|
880
|
+
.. code-block:: yaml
|
|
881
|
+
|
|
882
|
+
skillsets:
|
|
883
|
+
investigations:
|
|
884
|
+
name: Investigations
|
|
885
|
+
description: Prompt templates for graph investigations
|
|
886
|
+
enabled: true
|
|
887
|
+
skills:
|
|
888
|
+
summarize_node:
|
|
889
|
+
name: Summarize node
|
|
890
|
+
template: Summarize {{node_id}} for a security analyst.
|
|
891
|
+
parameters:
|
|
892
|
+
- name: node_id
|
|
893
|
+
type: string
|
|
894
|
+
"""
|
|
895
|
+
],
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
@field_validator("toolsets")
|
|
899
|
+
@classmethod
|
|
900
|
+
def validate_toolset_ids(cls, v: dict[str, ToolsetDef]) -> dict[str, ToolsetDef]:
|
|
901
|
+
for key in v:
|
|
902
|
+
validate_lower_snake_id(key)
|
|
903
|
+
return v
|
|
904
|
+
|
|
905
|
+
@field_validator("skillsets")
|
|
906
|
+
@classmethod
|
|
907
|
+
def validate_skillset_ids(cls, v: dict[str, SkillsetDef]) -> dict[str, SkillsetDef]:
|
|
908
|
+
for key in v:
|
|
909
|
+
validate_lower_snake_id(key)
|
|
910
|
+
return v
|
|
911
|
+
|
|
912
|
+
@field_validator("scheduled_queries", mode="before")
|
|
913
|
+
@classmethod
|
|
914
|
+
def coerce_scheduled_queries(cls, v: Any) -> Any:
|
|
915
|
+
"""Accept old dict format (key -> ScheduledQuery) as well as the new list format."""
|
|
916
|
+
if isinstance(v, dict):
|
|
917
|
+
return list(v.values())
|
|
918
|
+
return v
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def output_json_schema() -> dict[str, Any]:
|
|
922
|
+
schema = ReportingConfig.model_json_schema()
|
|
923
|
+
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
|
|
924
|
+
return schema
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def load_file(path: str) -> ReportingConfig:
|
|
928
|
+
"""Load a ``ReportingConfig`` from a YAML file at *path*."""
|
|
929
|
+
with open(path) as f:
|
|
930
|
+
return ReportingConfig.model_validate(yaml.safe_load(f))
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def dump_yaml(config: ReportingConfig) -> str:
|
|
934
|
+
"""Serialise *config* to a YAML string."""
|
|
935
|
+
return yaml.dump(
|
|
936
|
+
config.model_dump(),
|
|
937
|
+
default_flow_style=False,
|
|
938
|
+
allow_unicode=True,
|
|
939
|
+
)
|