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.
@@ -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 tqdm
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
- pass
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
- def getAtomicRedTeamRepoPath(self, atomic_red_team_repo_name:str = "atomic-red-team"):
189
- return self.path/atomic_red_team_repo_name
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(description="Splunk API username used for running appinspect.")
254
- splunk_api_password: str = Field(exclude=True, description="Splunk API password used for running appinspect.")
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()
@@ -136,4 +136,7 @@ SES_ATTACK_TACTICS_ID_MAPPING = {
136
136
  RBA_OBSERVABLE_ROLE_MAPPING = {
137
137
  "Attacker": 0,
138
138
  "Victim": 1
139
- }
139
+ }
140
+
141
+ # The relative path to the directory where any apps/packages will be downloaded
142
+ DOWNLOADS_DIRECTORY = "downloads"
@@ -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, Annotated, Union
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
- all_tests: None | List[AtomicTest] = output_dto.atomic_tests
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(AtomicTest.getAtomicByAtomicGuid(atomic_guid, all_tests))
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.: {[str(guid) for guid in missing_tests]}"
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
- print(missing_tests_string)
281
+ raise ValueError(missing_tests_string)
282
282
 
283
283
  return matched_tests + [AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests]
@@ -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
 
@@ -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
+ )