contentctl 4.3.4__py3-none-any.whl → 4.3.5__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.
- contentctl/actions/initialize.py +28 -12
- contentctl/actions/inspect.py +189 -91
- contentctl/actions/validate.py +3 -7
- contentctl/api.py +1 -1
- contentctl/contentctl.py +3 -0
- contentctl/enrichments/attack_enrichment.py +49 -81
- contentctl/helper/splunk_app.py +141 -10
- contentctl/input/director.py +5 -12
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +8 -6
- contentctl/objects/atomic.py +51 -77
- contentctl/objects/config.py +142 -19
- contentctl/objects/constants.py +4 -1
- contentctl/objects/detection_metadata.py +71 -0
- contentctl/objects/detection_stanza.py +79 -0
- contentctl/objects/detection_tags.py +6 -6
- contentctl/objects/enums.py +0 -2
- contentctl/objects/errors.py +187 -0
- contentctl/objects/savedsearches_conf.py +196 -0
- contentctl/output/conf_writer.py +4 -1
- contentctl/output/new_content_yml_output.py +4 -9
- {contentctl-4.3.4.dist-info → contentctl-4.3.5.dist-info}/METADATA +4 -4
- {contentctl-4.3.4.dist-info → contentctl-4.3.5.dist-info}/RECORD +25 -26
- contentctl/objects/ssa_detection.py +0 -157
- contentctl/objects/ssa_detection_tags.py +0 -138
- contentctl/objects/unit_test_old.py +0 -10
- contentctl/objects/unit_test_ssa.py +0 -31
- {contentctl-4.3.4.dist-info → contentctl-4.3.5.dist-info}/LICENSE.md +0 -0
- {contentctl-4.3.4.dist-info → contentctl-4.3.5.dist-info}/WHEEL +0 -0
- {contentctl-4.3.4.dist-info → contentctl-4.3.5.dist-info}/entry_points.txt +0 -0
contentctl/objects/config.py
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from os import environ
|
|
4
|
+
from datetime import datetime, UTC
|
|
5
|
+
from typing import Optional, Any, List, Union, Self
|
|
6
|
+
import random
|
|
7
|
+
from enum import StrEnum, auto
|
|
8
|
+
import pathlib
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from functools import partialmethod
|
|
12
|
+
|
|
13
|
+
import tqdm
|
|
14
|
+
import semantic_version
|
|
2
15
|
from pydantic import (
|
|
3
16
|
BaseModel, Field, field_validator,
|
|
4
17
|
field_serializer, ConfigDict, DirectoryPath,
|
|
5
18
|
PositiveInt, FilePath, HttpUrl, AnyUrl, model_validator,
|
|
6
19
|
ValidationInfo
|
|
7
20
|
)
|
|
21
|
+
|
|
22
|
+
from contentctl.objects.constants import DOWNLOADS_DIRECTORY
|
|
8
23
|
from contentctl.output.yml_writer import YmlWriter
|
|
9
|
-
from os import environ
|
|
10
|
-
from datetime import datetime, UTC
|
|
11
|
-
from typing import Optional,Any,Annotated,List,Union, Self
|
|
12
|
-
import semantic_version
|
|
13
|
-
import random
|
|
14
|
-
from enum import StrEnum, auto
|
|
15
|
-
import pathlib
|
|
16
24
|
from contentctl.helper.utils import Utils
|
|
17
|
-
from urllib.parse import urlparse
|
|
18
|
-
from abc import ABC, abstractmethod
|
|
19
25
|
from contentctl.objects.enums import PostTestBehavior, DetectionTestingMode
|
|
20
26
|
from contentctl.objects.detection import Detection
|
|
21
27
|
from contentctl.objects.annotated_types import APPID_TYPE
|
|
22
|
-
import
|
|
23
|
-
from functools import partialmethod
|
|
28
|
+
from contentctl.helper.splunk_app import SplunkApp
|
|
24
29
|
|
|
25
30
|
ENTERPRISE_SECURITY_UID = 263
|
|
26
31
|
COMMON_INFORMATION_MODEL_UID = 1621
|
|
@@ -171,7 +176,13 @@ class Config_Base(BaseModel):
|
|
|
171
176
|
return str(path)
|
|
172
177
|
|
|
173
178
|
class init(Config_Base):
|
|
174
|
-
|
|
179
|
+
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
180
|
+
bare: bool = Field(default=False, description="contentctl normally provides some some example content "
|
|
181
|
+
"(macros, stories, data_sources, and/or analytic stories). This option disables "
|
|
182
|
+
"initialization with that additional contnet. Note that even if --bare is used, it "
|
|
183
|
+
"init will still create the directory structure of the app, "
|
|
184
|
+
"include the app_template directory with default content, and content in "
|
|
185
|
+
"the deployment/ directory (since it is not yet easily customizable).")
|
|
175
186
|
|
|
176
187
|
|
|
177
188
|
# TODO (#266): disable the use_enum_values configuration
|
|
@@ -185,8 +196,45 @@ class validate(Config_Base):
|
|
|
185
196
|
build_api: bool = Field(default=False, description="Should api objects be built and output in the build_path?")
|
|
186
197
|
data_source_TA_validation: bool = Field(default=False, description="Validate latest TA information from Splunkbase")
|
|
187
198
|
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
@property
|
|
200
|
+
def external_repos_path(self)->pathlib.Path:
|
|
201
|
+
return self.path/"external_repos"
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def mitre_cti_repo_path(self)->pathlib.Path:
|
|
205
|
+
return self.external_repos_path/"cti"
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def atomic_red_team_repo_path(self):
|
|
209
|
+
return self.external_repos_path/"atomic-red-team"
|
|
210
|
+
|
|
211
|
+
@model_validator(mode="after")
|
|
212
|
+
def ensureEnrichmentReposPresent(self)->Self:
|
|
213
|
+
'''
|
|
214
|
+
Ensures that the enrichments repos, the atomic red team repo and the
|
|
215
|
+
mitre attack enrichment repo, are present at the inded path.
|
|
216
|
+
Raises a detailed exception if either of these are not present
|
|
217
|
+
when enrichments are enabled.
|
|
218
|
+
'''
|
|
219
|
+
if not self.enrichments:
|
|
220
|
+
return self
|
|
221
|
+
# If enrichments are enabled, ensure that all of the
|
|
222
|
+
# enrichment directories exist
|
|
223
|
+
missing_repos:list[str] = []
|
|
224
|
+
if not self.atomic_red_team_repo_path.is_dir():
|
|
225
|
+
missing_repos.append(f"https://github.com/redcanaryco/atomic-red-team {self.atomic_red_team_repo_path}")
|
|
226
|
+
|
|
227
|
+
if not self.mitre_cti_repo_path.is_dir():
|
|
228
|
+
missing_repos.append(f"https://github.com/mitre/cti {self.mitre_cti_repo_path}")
|
|
229
|
+
|
|
230
|
+
if len(missing_repos) > 0:
|
|
231
|
+
msg_list = ["The following repositories, which are required for enrichment, have not "
|
|
232
|
+
f"been checked out to the {self.external_repos_path} directory. "
|
|
233
|
+
"Please check them out using the following commands:"]
|
|
234
|
+
msg_list.extend([f"git clone --single-branch {repo_string}" for repo_string in missing_repos])
|
|
235
|
+
msg = '\n\t'.join(msg_list)
|
|
236
|
+
raise FileNotFoundError(msg)
|
|
237
|
+
return self
|
|
190
238
|
|
|
191
239
|
class report(validate):
|
|
192
240
|
#reporting takes no extra args, but we define it here so that it can be a mode on the command line
|
|
@@ -233,9 +281,6 @@ class build(validate):
|
|
|
233
281
|
return self.getBuildDir() / f"{self.app.appid}-{self.app.version}.tar.gz"
|
|
234
282
|
else:
|
|
235
283
|
return self.getBuildDir() / f"{self.app.appid}-latest.tar.gz"
|
|
236
|
-
|
|
237
|
-
def getSSAPath(self)->pathlib.Path:
|
|
238
|
-
return self.getBuildDir() / "ssa"
|
|
239
284
|
|
|
240
285
|
def getAPIPath(self)->pathlib.Path:
|
|
241
286
|
return self.getBuildDir() / "api"
|
|
@@ -249,11 +294,89 @@ class StackType(StrEnum):
|
|
|
249
294
|
classic = auto()
|
|
250
295
|
victoria = auto()
|
|
251
296
|
|
|
297
|
+
|
|
252
298
|
class inspect(build):
|
|
253
|
-
splunk_api_username: str = Field(
|
|
254
|
-
|
|
299
|
+
splunk_api_username: str = Field(
|
|
300
|
+
description="Splunk API username used for appinspect and Splunkbase downloads."
|
|
301
|
+
)
|
|
302
|
+
splunk_api_password: str = Field(
|
|
303
|
+
exclude=True,
|
|
304
|
+
description="Splunk API password used for appinspect and Splunkbase downloads."
|
|
305
|
+
)
|
|
306
|
+
enable_metadata_validation: bool = Field(
|
|
307
|
+
default=False,
|
|
308
|
+
description=(
|
|
309
|
+
"Flag indicating whether detection metadata validation and versioning enforcement "
|
|
310
|
+
"should be enabled."
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
enrichments: bool = Field(
|
|
314
|
+
default=True,
|
|
315
|
+
description=(
|
|
316
|
+
"[NOTE: enrichments must be ENABLED for inspect to run. Please adjust your config "
|
|
317
|
+
f"or CLI invocation appropriately] {validate.model_fields['enrichments'].description}"
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
# TODO (cmcginley): wording should change here if we want to be able to download any app from
|
|
321
|
+
# Splunkbase
|
|
322
|
+
previous_build: str | None = Field(
|
|
323
|
+
default=None,
|
|
324
|
+
description=(
|
|
325
|
+
"Local path to the previous app build for metatdata validation and versioning "
|
|
326
|
+
"enforcement (defaults to the latest release of the app published on Splunkbase)."
|
|
327
|
+
)
|
|
328
|
+
)
|
|
255
329
|
stack_type: StackType = Field(description="The type of your Splunk Cloud Stack")
|
|
256
330
|
|
|
331
|
+
@field_validator("enrichments", mode="after")
|
|
332
|
+
@classmethod
|
|
333
|
+
def validate_needed_flags_metadata_validation(cls, v: bool, info: ValidationInfo) -> bool:
|
|
334
|
+
"""
|
|
335
|
+
Validates that `enrichments` is True for the inspect action
|
|
336
|
+
|
|
337
|
+
:param v: the field's value
|
|
338
|
+
:type v: bool
|
|
339
|
+
:param info: the ValidationInfo to be used
|
|
340
|
+
:type info: :class:`pydantic.ValidationInfo`
|
|
341
|
+
|
|
342
|
+
:returns: bool, for v
|
|
343
|
+
:rtype: bool
|
|
344
|
+
"""
|
|
345
|
+
# Enforce that `enrichments` is True for the inspect action
|
|
346
|
+
if v is False:
|
|
347
|
+
raise ValueError("Field `enrichments` must be True for the `inspect` action")
|
|
348
|
+
|
|
349
|
+
return v
|
|
350
|
+
|
|
351
|
+
def get_previous_package_file_path(self) -> pathlib.Path:
|
|
352
|
+
"""
|
|
353
|
+
Returns a Path object for the path to the prior package build. If no path was provided, the
|
|
354
|
+
latest version is downloaded from Splunkbase and it's filepath is returned, and saved to the
|
|
355
|
+
in-memory config (so download doesn't happen twice in the same run).
|
|
356
|
+
|
|
357
|
+
:returns: Path object to previous app build
|
|
358
|
+
:rtype: :class:`pathlib.Path`
|
|
359
|
+
"""
|
|
360
|
+
previous_build_path = self.previous_build
|
|
361
|
+
# Download the previous build as the latest release on Splunkbase if no path was provided
|
|
362
|
+
if previous_build_path is None:
|
|
363
|
+
print(
|
|
364
|
+
f"Downloading latest {self.app.label} build from Splunkbase to serve as previous "
|
|
365
|
+
"build during validation..."
|
|
366
|
+
)
|
|
367
|
+
app = SplunkApp(app_uid=self.app.uid)
|
|
368
|
+
previous_build_path = app.download(
|
|
369
|
+
out=pathlib.Path(DOWNLOADS_DIRECTORY),
|
|
370
|
+
username=self.splunk_api_username,
|
|
371
|
+
password=self.splunk_api_password,
|
|
372
|
+
is_dir=True,
|
|
373
|
+
overwrite=True
|
|
374
|
+
)
|
|
375
|
+
print(f"Latest release downloaded from Splunkbase to: {previous_build_path}")
|
|
376
|
+
self.previous_build = str(previous_build_path)
|
|
377
|
+
return pathlib.Path(previous_build_path)
|
|
378
|
+
|
|
379
|
+
|
|
257
380
|
class NewContentType(StrEnum):
|
|
258
381
|
detection = auto()
|
|
259
382
|
story = auto()
|
contentctl/objects/constants.py
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field, field_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DetectionMetadata(BaseModel):
|
|
8
|
+
"""
|
|
9
|
+
A model of the metadata line in a detection stanza in savedsearches.conf
|
|
10
|
+
"""
|
|
11
|
+
# A bool indicating whether the detection is deprecated (serialized as an int, 1 or 0)
|
|
12
|
+
deprecated: bool = Field(...)
|
|
13
|
+
|
|
14
|
+
# A UUID identifying the detection
|
|
15
|
+
detection_id: uuid.UUID = Field(...)
|
|
16
|
+
|
|
17
|
+
# The version of the detection
|
|
18
|
+
detection_version: int = Field(...)
|
|
19
|
+
|
|
20
|
+
# The time the detection was published. **NOTE** This field was added to the metadata in ESCU
|
|
21
|
+
# as of v4.39.0
|
|
22
|
+
publish_time: float = Field(...)
|
|
23
|
+
|
|
24
|
+
class Config:
|
|
25
|
+
# Allowing for future fields that may be added to the metadata JSON
|
|
26
|
+
extra = "allow"
|
|
27
|
+
|
|
28
|
+
@field_validator("deprecated", mode="before")
|
|
29
|
+
@classmethod
|
|
30
|
+
def validate_deprecated(cls, v: Any) -> Any:
|
|
31
|
+
"""
|
|
32
|
+
Convert str to int, and then ints to bools for deprecated; raise if not 0 or 1 in the case
|
|
33
|
+
of an int, or if str cannot be converted to int.
|
|
34
|
+
|
|
35
|
+
:param v: the value passed
|
|
36
|
+
:type v: :class:`typing.Any`
|
|
37
|
+
|
|
38
|
+
:returns: the value
|
|
39
|
+
:rtype: :class:`typing.Any`
|
|
40
|
+
"""
|
|
41
|
+
if isinstance(v, str):
|
|
42
|
+
try:
|
|
43
|
+
v = int(v)
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
raise ValueError(f"Cannot convert str value ({v}) to int: {e}") from e
|
|
46
|
+
if isinstance(v, int):
|
|
47
|
+
if not (0 <= v <= 1):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Value for field 'deprecated' ({v}) must be 0 or 1, if not a bool."
|
|
50
|
+
)
|
|
51
|
+
v = bool(v)
|
|
52
|
+
return v
|
|
53
|
+
|
|
54
|
+
@field_validator("detection_version", mode="before")
|
|
55
|
+
@classmethod
|
|
56
|
+
def validate_detection_version(cls, v: Any) -> Any:
|
|
57
|
+
"""
|
|
58
|
+
Convert str to int; raise if str cannot be converted to int.
|
|
59
|
+
|
|
60
|
+
:param v: the value passed
|
|
61
|
+
:type v: :class:`typing.Any`
|
|
62
|
+
|
|
63
|
+
:returns: the value
|
|
64
|
+
:rtype: :class:`typing.Any`
|
|
65
|
+
"""
|
|
66
|
+
if isinstance(v, str):
|
|
67
|
+
try:
|
|
68
|
+
v = int(v)
|
|
69
|
+
except ValueError as e:
|
|
70
|
+
raise ValueError(f"Cannot convert str value ({v}) to int: {e}") from e
|
|
71
|
+
return v
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from typing import ClassVar
|
|
2
|
+
import hashlib
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, computed_field
|
|
6
|
+
|
|
7
|
+
from contentctl.objects.detection_metadata import DetectionMetadata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DetectionStanza(BaseModel):
|
|
11
|
+
"""
|
|
12
|
+
A model representing a stanza for a detection in savedsearches.conf
|
|
13
|
+
"""
|
|
14
|
+
# The lines that comprise this stanza, in the order they appear in the conf
|
|
15
|
+
lines: list[str] = Field(...)
|
|
16
|
+
|
|
17
|
+
# The full name of the detection (e.g. "ESCU - My Detection - Rule")
|
|
18
|
+
name: str = Field(...)
|
|
19
|
+
|
|
20
|
+
# The key prefix indicating the metadata attribute
|
|
21
|
+
METADATA_LINE_PREFIX: ClassVar[str] = "action.correlationsearch.metadata = "
|
|
22
|
+
|
|
23
|
+
@computed_field
|
|
24
|
+
@cached_property
|
|
25
|
+
def metadata(self) -> DetectionMetadata:
|
|
26
|
+
"""
|
|
27
|
+
The metadata extracted from the stanza. Using the provided lines, parse out the metadata
|
|
28
|
+
|
|
29
|
+
:returns: the detection stanza's metadata
|
|
30
|
+
:rtype: :class:`contentctl.objects.detection_metadata.DetectionMetadata`
|
|
31
|
+
"""
|
|
32
|
+
# Set a variable to store the metadata line in
|
|
33
|
+
meta_line: str | None = None
|
|
34
|
+
|
|
35
|
+
# Iterate over the lines to look for the metadata line
|
|
36
|
+
for line in self.lines:
|
|
37
|
+
if line.startswith(DetectionStanza.METADATA_LINE_PREFIX):
|
|
38
|
+
# If we find a matching line more than once, we've hit an error
|
|
39
|
+
if meta_line is not None:
|
|
40
|
+
raise Exception(
|
|
41
|
+
f"Metadata for detection '{self.name}' found twice in stanza."
|
|
42
|
+
)
|
|
43
|
+
meta_line = line
|
|
44
|
+
|
|
45
|
+
# Report if we could not find the metadata line
|
|
46
|
+
if meta_line is None:
|
|
47
|
+
raise Exception(f"No metadata for detection '{self.name}' found in stanza.")
|
|
48
|
+
|
|
49
|
+
# Parse the metadata JSON into a model
|
|
50
|
+
return DetectionMetadata.model_validate_json(meta_line[len(DetectionStanza.METADATA_LINE_PREFIX):])
|
|
51
|
+
|
|
52
|
+
@computed_field
|
|
53
|
+
@cached_property
|
|
54
|
+
def hash(self) -> str:
|
|
55
|
+
"""
|
|
56
|
+
The SHA256 hash of the lines of the stanza, excluding the metadata line
|
|
57
|
+
|
|
58
|
+
:returns: hexdigest
|
|
59
|
+
:rtype: str
|
|
60
|
+
"""
|
|
61
|
+
hash = hashlib.sha256()
|
|
62
|
+
for line in self.lines:
|
|
63
|
+
if not line.startswith(DetectionStanza.METADATA_LINE_PREFIX):
|
|
64
|
+
hash.update(line.encode("utf-8"))
|
|
65
|
+
return hash.hexdigest()
|
|
66
|
+
|
|
67
|
+
def version_should_be_bumped(self, previous: "DetectionStanza") -> bool:
|
|
68
|
+
"""
|
|
69
|
+
A helper method that compares this stanza against the same stanza from a previous build;
|
|
70
|
+
returns True if the version still needs to be bumped (e.g. the detection was changed but
|
|
71
|
+
the version was not), False otherwise.
|
|
72
|
+
|
|
73
|
+
:param previous: the previous build's DetectionStanza for comparison
|
|
74
|
+
:type previous: :class:`contentctl.objects.detection_stanza.DetectionStanza`
|
|
75
|
+
|
|
76
|
+
:returns: True if the version still needs to be bumped
|
|
77
|
+
:rtype: bool
|
|
78
|
+
"""
|
|
79
|
+
return (self.hash != previous.hash) and (self.metadata.detection_version <= previous.metadata.detection_version)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
import uuid
|
|
3
|
-
from typing import TYPE_CHECKING, List, Optional,
|
|
3
|
+
from typing import TYPE_CHECKING, List, Optional, Union
|
|
4
4
|
from pydantic import (
|
|
5
5
|
BaseModel,
|
|
6
6
|
Field,
|
|
@@ -32,7 +32,7 @@ from contentctl.objects.enums import (
|
|
|
32
32
|
RiskLevel,
|
|
33
33
|
SecurityContentProductName
|
|
34
34
|
)
|
|
35
|
-
from contentctl.objects.atomic import AtomicTest
|
|
35
|
+
from contentctl.objects.atomic import AtomicEnrichment, AtomicTest
|
|
36
36
|
from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE, CVE_TYPE
|
|
37
37
|
|
|
38
38
|
# TODO (#266): disable the use_enum_values configuration
|
|
@@ -240,7 +240,7 @@ class DetectionTags(BaseModel):
|
|
|
240
240
|
if output_dto is None:
|
|
241
241
|
raise ValueError("Context not provided to detection.detection_tags.atomic_guid validator")
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
atomic_enrichment: AtomicEnrichment = output_dto.atomic_enrichment
|
|
244
244
|
|
|
245
245
|
matched_tests: List[AtomicTest] = []
|
|
246
246
|
missing_tests: List[UUID4] = []
|
|
@@ -254,7 +254,7 @@ class DetectionTags(BaseModel):
|
|
|
254
254
|
badly_formatted_guids.append(str(atomic_guid_str))
|
|
255
255
|
continue
|
|
256
256
|
try:
|
|
257
|
-
matched_tests.append(
|
|
257
|
+
matched_tests.append(atomic_enrichment.getAtomic(atomic_guid))
|
|
258
258
|
except Exception:
|
|
259
259
|
missing_tests.append(atomic_guid)
|
|
260
260
|
|
|
@@ -265,7 +265,7 @@ class DetectionTags(BaseModel):
|
|
|
265
265
|
f"\n\tPlease review the output above for potential exception(s) when parsing the "
|
|
266
266
|
"Atomic Red Team Repo."
|
|
267
267
|
"\n\tVerify that these auto_generated_guid exist and try updating/pulling the "
|
|
268
|
-
f"repo again
|
|
268
|
+
f"repo again: {[str(guid) for guid in missing_tests]}"
|
|
269
269
|
)
|
|
270
270
|
else:
|
|
271
271
|
missing_tests_string = ""
|
|
@@ -278,6 +278,6 @@ class DetectionTags(BaseModel):
|
|
|
278
278
|
raise ValueError(f"{bad_guids_string}{missing_tests_string}")
|
|
279
279
|
|
|
280
280
|
elif len(missing_tests) > 0:
|
|
281
|
-
|
|
281
|
+
raise ValueError(missing_tests_string)
|
|
282
282
|
|
|
283
283
|
return matched_tests + [AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests]
|
contentctl/objects/enums.py
CHANGED
|
@@ -54,7 +54,6 @@ class SecurityContentType(enum.Enum):
|
|
|
54
54
|
deployments = 7
|
|
55
55
|
investigations = 8
|
|
56
56
|
unit_tests = 9
|
|
57
|
-
ssa_detections = 10
|
|
58
57
|
data_sources = 11
|
|
59
58
|
|
|
60
59
|
# Bringing these changes back in line will take some time after
|
|
@@ -69,7 +68,6 @@ class SecurityContentType(enum.Enum):
|
|
|
69
68
|
|
|
70
69
|
class SecurityContentProduct(enum.Enum):
|
|
71
70
|
SPLUNK_APP = 1
|
|
72
|
-
SSA = 2
|
|
73
71
|
API = 3
|
|
74
72
|
CUSTOM = 4
|
|
75
73
|
|
contentctl/objects/errors.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
|
|
1
5
|
class ValidationFailed(Exception):
|
|
2
6
|
"""Indicates not an error in execution, but a validation failure"""
|
|
3
7
|
pass
|
|
@@ -16,3 +20,186 @@ class ServerError(IntegrationTestingError):
|
|
|
16
20
|
class ClientError(IntegrationTestingError):
|
|
17
21
|
"""An error encounterd during integration testing, on the client's side (locally)"""
|
|
18
22
|
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MetadataValidationError(Exception, ABC):
|
|
26
|
+
"""
|
|
27
|
+
Base class for any errors arising from savedsearches.conf detection metadata validation
|
|
28
|
+
"""
|
|
29
|
+
# The name of the rule the error relates to
|
|
30
|
+
rule_name: str
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def long_message(self) -> str:
|
|
35
|
+
"""
|
|
36
|
+
A long-form error message
|
|
37
|
+
:returns: a str, the message
|
|
38
|
+
"""
|
|
39
|
+
raise NotImplementedError()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def short_message(self) -> str:
|
|
44
|
+
"""
|
|
45
|
+
A short-form error message
|
|
46
|
+
:returns: a str, the message
|
|
47
|
+
"""
|
|
48
|
+
raise NotImplementedError()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DetectionMissingError(MetadataValidationError):
|
|
52
|
+
"""
|
|
53
|
+
An error indicating a detection in the prior build could not be found in the current build
|
|
54
|
+
"""
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
rule_name: str,
|
|
58
|
+
*args: object
|
|
59
|
+
) -> None:
|
|
60
|
+
self.rule_name = rule_name
|
|
61
|
+
super().__init__(self.long_message, *args)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def long_message(self) -> str:
|
|
65
|
+
"""
|
|
66
|
+
A long-form error message
|
|
67
|
+
:returns: a str, the message
|
|
68
|
+
"""
|
|
69
|
+
return (
|
|
70
|
+
f"Rule '{self.rule_name}' in previous build not found in current build; "
|
|
71
|
+
"detection may have been removed or renamed."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def short_message(self) -> str:
|
|
76
|
+
"""
|
|
77
|
+
A short-form error message
|
|
78
|
+
:returns: a str, the message
|
|
79
|
+
"""
|
|
80
|
+
return (
|
|
81
|
+
"Detection from previous build not found in current build."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DetectionIDError(MetadataValidationError):
|
|
86
|
+
"""
|
|
87
|
+
An error indicating the detection ID may have changed between builds
|
|
88
|
+
"""
|
|
89
|
+
# The ID from the current build
|
|
90
|
+
current_id: UUID
|
|
91
|
+
|
|
92
|
+
# The ID from the previous build
|
|
93
|
+
previous_id: UUID
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
rule_name: str,
|
|
98
|
+
current_id: UUID,
|
|
99
|
+
previous_id: UUID,
|
|
100
|
+
*args: object
|
|
101
|
+
) -> None:
|
|
102
|
+
self.rule_name = rule_name
|
|
103
|
+
self.current_id = current_id
|
|
104
|
+
self.previous_id = previous_id
|
|
105
|
+
super().__init__(self.long_message, *args)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def long_message(self) -> str:
|
|
109
|
+
"""
|
|
110
|
+
A long-form error message
|
|
111
|
+
:returns: a str, the message
|
|
112
|
+
"""
|
|
113
|
+
return (
|
|
114
|
+
f"Rule '{self.rule_name}' has ID {self.current_id} in current build "
|
|
115
|
+
f"and {self.previous_id} in previous build; detection IDs and "
|
|
116
|
+
"names should not change for the same detection between releases."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def short_message(self) -> str:
|
|
121
|
+
"""
|
|
122
|
+
A short-form error message
|
|
123
|
+
:returns: a str, the message
|
|
124
|
+
"""
|
|
125
|
+
return (
|
|
126
|
+
f"Detection ID {self.current_id} in current build does not match ID {self.previous_id} in previous build."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class VersioningError(MetadataValidationError, ABC):
|
|
131
|
+
"""
|
|
132
|
+
A base class for any metadata validation errors relating to detection versioning
|
|
133
|
+
"""
|
|
134
|
+
# The version in the current build
|
|
135
|
+
current_version: int
|
|
136
|
+
|
|
137
|
+
# The version in the previous build
|
|
138
|
+
previous_version: int
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
rule_name: str,
|
|
143
|
+
current_version: int,
|
|
144
|
+
previous_version: int,
|
|
145
|
+
*args: object
|
|
146
|
+
) -> None:
|
|
147
|
+
self.rule_name = rule_name
|
|
148
|
+
self.current_version = current_version
|
|
149
|
+
self.previous_version = previous_version
|
|
150
|
+
super().__init__(self.long_message, *args)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class VersionDecrementedError(VersioningError):
|
|
154
|
+
"""
|
|
155
|
+
An error indicating the version number went down between builds
|
|
156
|
+
"""
|
|
157
|
+
@property
|
|
158
|
+
def long_message(self) -> str:
|
|
159
|
+
"""
|
|
160
|
+
A long-form error message
|
|
161
|
+
:returns: a str, the message
|
|
162
|
+
"""
|
|
163
|
+
return (
|
|
164
|
+
f"Rule '{self.rule_name}' has version {self.current_version} in "
|
|
165
|
+
f"current build and {self.previous_version} in previous build; "
|
|
166
|
+
"detection versions cannot decrease in successive builds."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def short_message(self) -> str:
|
|
171
|
+
"""
|
|
172
|
+
A short-form error message
|
|
173
|
+
:returns: a str, the message
|
|
174
|
+
"""
|
|
175
|
+
return (
|
|
176
|
+
f"Detection version ({self.current_version}) in current build is less than version "
|
|
177
|
+
f"({self.previous_version}) in previous build."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class VersionBumpingError(VersioningError):
|
|
182
|
+
"""
|
|
183
|
+
An error indicating the detection changed but its version wasn't bumped appropriately
|
|
184
|
+
"""
|
|
185
|
+
@property
|
|
186
|
+
def long_message(self) -> str:
|
|
187
|
+
"""
|
|
188
|
+
A long-form error message
|
|
189
|
+
:returns: a str, the message
|
|
190
|
+
"""
|
|
191
|
+
return (
|
|
192
|
+
f"Rule '{self.rule_name}' has changed in current build compared to previous "
|
|
193
|
+
"build (stanza hashes differ); the detection version should be bumped "
|
|
194
|
+
f"to at least {self.previous_version + 1}."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def short_message(self) -> str:
|
|
199
|
+
"""
|
|
200
|
+
A short-form error message
|
|
201
|
+
:returns: a str, the message
|
|
202
|
+
"""
|
|
203
|
+
return (
|
|
204
|
+
f"Detection version in current build should be bumped to at least {self.previous_version + 1}."
|
|
205
|
+
)
|