contentctl 5.1.0__py3-none-any.whl → 5.3.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.
Files changed (45) hide show
  1. contentctl/actions/build.py +5 -43
  2. contentctl/actions/detection_testing/DetectionTestingManager.py +64 -24
  3. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +147 -43
  4. contentctl/actions/detection_testing/views/DetectionTestingView.py +5 -6
  5. contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +2 -0
  6. contentctl/actions/initialize.py +35 -9
  7. contentctl/actions/release_notes.py +14 -12
  8. contentctl/actions/test.py +16 -20
  9. contentctl/actions/validate.py +8 -15
  10. contentctl/helper/utils.py +69 -20
  11. contentctl/input/director.py +147 -119
  12. contentctl/input/yml_reader.py +39 -27
  13. contentctl/objects/abstract_security_content_objects/detection_abstract.py +121 -20
  14. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +548 -8
  15. contentctl/objects/baseline.py +24 -6
  16. contentctl/objects/config.py +32 -8
  17. contentctl/objects/content_versioning_service.py +508 -0
  18. contentctl/objects/correlation_search.py +53 -63
  19. contentctl/objects/dashboard.py +15 -1
  20. contentctl/objects/data_source.py +15 -1
  21. contentctl/objects/deployment.py +23 -9
  22. contentctl/objects/detection.py +2 -0
  23. contentctl/objects/enums.py +28 -18
  24. contentctl/objects/investigation.py +40 -20
  25. contentctl/objects/lookup.py +77 -8
  26. contentctl/objects/macro.py +19 -4
  27. contentctl/objects/playbook.py +16 -2
  28. contentctl/objects/rba.py +1 -33
  29. contentctl/objects/removed_security_content_object.py +50 -0
  30. contentctl/objects/security_content_object.py +1 -0
  31. contentctl/objects/story.py +37 -5
  32. contentctl/output/api_json_output.py +5 -3
  33. contentctl/output/attack_nav_output.py +11 -4
  34. contentctl/output/attack_nav_writer.py +53 -37
  35. contentctl/output/conf_output.py +9 -1
  36. contentctl/output/runtime_csv_writer.py +111 -0
  37. contentctl/output/svg_output.py +4 -5
  38. contentctl/output/templates/savedsearches_detections.j2 +2 -6
  39. contentctl/output/templates/transforms.j2 +2 -2
  40. {contentctl-5.1.0.dist-info → contentctl-5.3.0.dist-info}/METADATA +4 -3
  41. {contentctl-5.1.0.dist-info → contentctl-5.3.0.dist-info}/RECORD +44 -42
  42. {contentctl-5.1.0.dist-info → contentctl-5.3.0.dist-info}/WHEEL +1 -1
  43. contentctl/output/data_source_writer.py +0 -52
  44. {contentctl-5.1.0.dist-info → contentctl-5.3.0.dist-info}/LICENSE.md +0 -0
  45. {contentctl-5.1.0.dist-info → contentctl-5.3.0.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
+
3
+ import pathlib
2
4
  from typing import Self
3
- from pydantic import model_validator, Field, FilePath
4
5
 
6
+ from pydantic import Field, FilePath, field_validator, model_validator
5
7
 
8
+ from contentctl.objects.enums import ContentStatus, PlaybookType
6
9
  from contentctl.objects.playbook_tags import PlaybookTag
7
10
  from contentctl.objects.security_content_object import SecurityContentObject
8
- from contentctl.objects.enums import PlaybookType
9
11
 
10
12
 
11
13
  class Playbook(SecurityContentObject):
@@ -19,6 +21,16 @@ class Playbook(SecurityContentObject):
19
21
  playbook: str = Field(min_length=4)
20
22
  app_list: list[str] = Field(..., min_length=0)
21
23
  tags: PlaybookTag = Field(...)
24
+ status: ContentStatus = ContentStatus.production
25
+
26
+ @field_validator("status", mode="after")
27
+ @classmethod
28
+ def NarrowStatus(cls, status: ContentStatus) -> ContentStatus:
29
+ return cls.NarrowStatusTemplate(status, [ContentStatus.production])
30
+
31
+ @classmethod
32
+ def containing_folder(cls) -> pathlib.Path:
33
+ return pathlib.Path("playbooks")
22
34
 
23
35
  @model_validator(mode="after")
24
36
  def ensureJsonAndPyFilesExist(self) -> Self:
@@ -66,3 +78,5 @@ class Playbook(SecurityContentObject):
66
78
  )
67
79
 
68
80
  return self
81
+ return self
82
+ return self
contentctl/objects/rba.py CHANGED
@@ -4,9 +4,7 @@ from abc import ABC
4
4
  from enum import Enum
5
5
  from typing import Annotated, Set
6
6
 
7
- from pydantic import BaseModel, Field, computed_field, model_serializer
8
-
9
- from contentctl.objects.enums import RiskSeverity
7
+ from pydantic import BaseModel, Field, model_serializer
10
8
 
11
9
  RiskScoreValue_Type = Annotated[int, Field(ge=1, le=100)]
12
10
 
@@ -108,36 +106,6 @@ class RBAObject(BaseModel, ABC):
108
106
  risk_objects: Annotated[Set[RiskObject], Field(min_length=1)]
109
107
  threat_objects: Set[ThreatObject]
110
108
 
111
- @computed_field
112
- @property
113
- def risk_score(self) -> RiskScoreValue_Type:
114
- # First get the maximum score associated with
115
- # a risk object. If there are no objects, then
116
- # we should throw an exception.
117
- if len(self.risk_objects) == 0:
118
- raise Exception(
119
- "There must be at least one Risk Object present to get Severity."
120
- )
121
- return max([risk_object.score for risk_object in self.risk_objects])
122
-
123
- @computed_field
124
- @property
125
- def severity(self) -> RiskSeverity:
126
- if 0 <= self.risk_score <= 20:
127
- return RiskSeverity.INFORMATIONAL
128
- elif 20 < self.risk_score <= 40:
129
- return RiskSeverity.LOW
130
- elif 40 < self.risk_score <= 60:
131
- return RiskSeverity.MEDIUM
132
- elif 60 < self.risk_score <= 80:
133
- return RiskSeverity.HIGH
134
- elif 80 < self.risk_score <= 100:
135
- return RiskSeverity.CRITICAL
136
- else:
137
- raise Exception(
138
- f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}"
139
- )
140
-
141
109
  @model_serializer
142
110
  def serialize_rba(self) -> dict[str, str | list[dict[str, str | int]]]:
143
111
  return {
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ from functools import cached_property
5
+
6
+ from pydantic import ConfigDict, HttpUrl, computed_field, field_validator
7
+
8
+ from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import (
9
+ SecurityContentObject_Abstract,
10
+ )
11
+ from contentctl.objects.enums import ContentStatus
12
+
13
+
14
+ class SecurityContentObject(SecurityContentObject_Abstract):
15
+ pass
16
+
17
+
18
+ class RemovedSecurityContentObject(SecurityContentObject):
19
+ # We MUST allow extra fields here because the python definitions of the underlying
20
+ # objects can change. We do not want to throw pasing errors on any of these, but we will
21
+ # only expose fields that are defined in the SecurityContentObject definiton directly
22
+ model_config = ConfigDict(validate_default=True, extra="ignore")
23
+ status: ContentStatus
24
+
25
+ @field_validator("status", mode="after")
26
+ @classmethod
27
+ def NarrowStatus(cls, status: ContentStatus) -> ContentStatus:
28
+ return cls.NarrowStatusTemplate(status, [ContentStatus.removed])
29
+
30
+ @computed_field
31
+ @cached_property
32
+ def migration_guide(self) -> HttpUrl:
33
+ """
34
+ A link to the research site containing a migration guide for the content
35
+
36
+ :returns: URL to the research site
37
+ :rtype: HTTPUrl
38
+ """
39
+ # this is split up so that we can explicilty ignore the warning on constructing
40
+ # the HttpUrl but catch other type issues
41
+ # link = f"https://research.splunk.com/migration_guide/{self.id}"
42
+ # This can likely be dynamically generated per detection, but for now we
43
+ # just make it static
44
+ link = "https://research.splunk.com/migration_guide/"
45
+
46
+ return HttpUrl(url=link) # type: ignore
47
+
48
+ @classmethod
49
+ def containing_folder(cls) -> pathlib.Path:
50
+ return pathlib.Path("removed")
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import (
3
4
  SecurityContentObject_Abstract,
4
5
  )
@@ -1,9 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- from typing import TYPE_CHECKING, List, Literal
5
-
6
- from pydantic import Field, computed_field, model_serializer, model_validator
4
+ from functools import cached_property
5
+ from typing import TYPE_CHECKING, List
6
+
7
+ from pydantic import (
8
+ Field,
9
+ HttpUrl,
10
+ computed_field,
11
+ field_validator,
12
+ model_serializer,
13
+ model_validator,
14
+ )
7
15
 
8
16
  from contentctl.objects.story_tags import StoryTags
9
17
 
@@ -14,20 +22,40 @@ if TYPE_CHECKING:
14
22
  from contentctl.objects.detection import Detection
15
23
  from contentctl.objects.investigation import Investigation
16
24
 
17
- from contentctl.objects.enums import DetectionStatus
25
+ import pathlib
26
+
27
+ from contentctl.objects.enums import ContentStatus
18
28
  from contentctl.objects.security_content_object import SecurityContentObject
19
29
 
20
30
 
21
31
  class Story(SecurityContentObject):
22
32
  narrative: str = Field(...)
23
33
  tags: StoryTags = Field(...)
24
- status: Literal[DetectionStatus.production, DetectionStatus.deprecated]
34
+ status: ContentStatus
25
35
  # These are updated when detection and investigation objects are created.
26
36
  # Specifically in the model_post_init functions
27
37
  detections: List[Detection] = []
28
38
  investigations: List[Investigation] = []
29
39
  baselines: List[Baseline] = []
30
40
 
41
+ @field_validator("status", mode="after")
42
+ @classmethod
43
+ def NarrowStatus(cls, status: ContentStatus) -> ContentStatus:
44
+ return cls.NarrowStatusTemplate(
45
+ status, [ContentStatus.production, ContentStatus.deprecated]
46
+ )
47
+
48
+ @classmethod
49
+ def containing_folder(cls) -> pathlib.Path:
50
+ return pathlib.Path("stories")
51
+
52
+ @computed_field
53
+ @cached_property
54
+ def researchSiteLink(self) -> HttpUrl:
55
+ return HttpUrl(
56
+ url=f"https://research.splunk.com/stories/{self.name.lower().replace(' ', '_')}"
57
+ ) # type:ignore
58
+
31
59
  @computed_field
32
60
  @property
33
61
  def data_sources(self) -> list[DataSource]:
@@ -145,3 +173,7 @@ class Story(SecurityContentObject):
145
173
  @property
146
174
  def baseline_names(self) -> List[str]:
147
175
  return [baseline.name for baseline in self.baselines]
176
+
177
+ @classmethod
178
+ def static_get_conf_stanza_name(cls, name: str, app: CustomApp) -> str:
179
+ return name
@@ -1,14 +1,15 @@
1
1
  from __future__ import annotations
2
+
2
3
  from typing import TYPE_CHECKING
3
4
 
4
5
  if TYPE_CHECKING:
6
+ from contentctl.objects.baseline import Baseline
7
+ from contentctl.objects.deployment import Deployment
5
8
  from contentctl.objects.detection import Detection
9
+ from contentctl.objects.investigation import Investigation
6
10
  from contentctl.objects.lookup import Lookup
7
11
  from contentctl.objects.macro import Macro
8
12
  from contentctl.objects.story import Story
9
- from contentctl.objects.baseline import Baseline
10
- from contentctl.objects.investigation import Investigation
11
- from contentctl.objects.deployment import Deployment
12
13
 
13
14
  import os
14
15
  import pathlib
@@ -42,6 +43,7 @@ class ApiJsonOutput:
42
43
  "search",
43
44
  "how_to_implement",
44
45
  "known_false_positives",
46
+ "rba",
45
47
  "references",
46
48
  "datamodel",
47
49
  "macros",
@@ -1,5 +1,5 @@
1
- from typing import List, Union
2
1
  import pathlib
2
+ from typing import List, Union
3
3
 
4
4
  from contentctl.objects.detection import Detection
5
5
  from contentctl.output.attack_nav_writer import AttackNavWriter
@@ -10,14 +10,21 @@ class AttackNavOutput:
10
10
  self, detections: List[Detection], output_path: pathlib.Path
11
11
  ) -> None:
12
12
  techniques: dict[str, dict[str, Union[List[str], int]]] = {}
13
+
13
14
  for detection in detections:
14
15
  for tactic in detection.tags.mitre_attack_id:
15
16
  if tactic not in techniques:
16
17
  techniques[tactic] = {"score": 0, "file_paths": []}
17
18
 
18
- detection_url = f"https://github.com/splunk/security_content/blob/develop/detections/{detection.source}/{detection.file_path.name}"
19
- techniques[tactic]["score"] += 1
20
- techniques[tactic]["file_paths"].append(detection_url)
19
+ detection_type = detection.source
20
+ detection_id = detection.id
21
+
22
+ # Store all three pieces of information separately
23
+ detection_info = f"{detection_type}|{detection_id}|{detection.name}"
24
+
25
+ techniques[tactic]["score"] = techniques[tactic].get("score", 0) + 1
26
+ if isinstance(techniques[tactic]["file_paths"], list):
27
+ techniques[tactic]["file_paths"].append(detection_info)
21
28
 
22
29
  """
23
30
  for detection in objects:
@@ -1,11 +1,11 @@
1
1
  import json
2
- from typing import Union, List
3
2
  import pathlib
3
+ from typing import List, Union
4
4
 
5
- VERSION = "4.3"
5
+ VERSION = "4.5"
6
6
  NAME = "Detection Coverage"
7
- DESCRIPTION = "security_content detection coverage"
8
- DOMAIN = "mitre-enterprise"
7
+ DESCRIPTION = "Security Content Detection Coverage"
8
+ DOMAIN = "enterprise-attack"
9
9
 
10
10
 
11
11
  class AttackNavWriter:
@@ -14,52 +14,68 @@ class AttackNavWriter:
14
14
  mitre_techniques: dict[str, dict[str, Union[List[str], int]]],
15
15
  output_path: pathlib.Path,
16
16
  ) -> None:
17
- max_count = 0
18
- for technique_id in mitre_techniques.keys():
19
- if mitre_techniques[technique_id]["score"] > max_count:
20
- max_count = mitre_techniques[technique_id]["score"]
17
+ max_count = max(
18
+ (technique["score"] for technique in mitre_techniques.values()), default=0
19
+ )
21
20
 
22
21
  layer_json = {
23
- "version": VERSION,
22
+ "versions": {"attack": "16", "navigator": "5.1.0", "layer": VERSION},
24
23
  "name": NAME,
25
24
  "description": DESCRIPTION,
26
25
  "domain": DOMAIN,
27
26
  "techniques": [],
27
+ "gradient": {
28
+ "colors": ["#ffffff", "#66b1ff", "#096ed7"],
29
+ "minValue": 0,
30
+ "maxValue": max_count,
31
+ },
32
+ "filters": {
33
+ "platforms": [
34
+ "Windows",
35
+ "Linux",
36
+ "macOS",
37
+ "Network",
38
+ "AWS",
39
+ "GCP",
40
+ "Azure",
41
+ "Azure AD",
42
+ "Office 365",
43
+ "SaaS",
44
+ ]
45
+ },
46
+ "layout": {
47
+ "layout": "side",
48
+ "showName": True,
49
+ "showID": True,
50
+ "showAggregateScores": False,
51
+ },
52
+ "legendItems": [
53
+ {"label": "No detections", "color": "#ffffff"},
54
+ {"label": "Has detections", "color": "#66b1ff"},
55
+ ],
56
+ "showTacticRowBackground": True,
57
+ "tacticRowBackground": "#dddddd",
58
+ "selectTechniquesAcrossTactics": True,
28
59
  }
29
60
 
30
- layer_json["gradient"] = {
31
- "colors": ["#ffffff", "#66b1ff", "#096ed7"],
32
- "minValue": 0,
33
- "maxValue": max_count,
34
- }
35
-
36
- layer_json["filters"] = {
37
- "platforms": [
38
- "Windows",
39
- "Linux",
40
- "macOS",
41
- "AWS",
42
- "GCP",
43
- "Azure",
44
- "Office 365",
45
- "SaaS",
46
- ]
47
- }
61
+ for technique_id, data in mitre_techniques.items():
62
+ links = []
63
+ for detection_info in data["file_paths"]:
64
+ # Split the detection info into its components
65
+ detection_type, detection_id, detection_name = detection_info.split("|")
48
66
 
49
- layer_json["legendItems"] = [
50
- {"label": "NO available detections", "color": "#ffffff"},
51
- {"label": "Some detections available", "color": "#66b1ff"},
52
- ]
67
+ # Construct research website URL (without the name)
68
+ research_url = (
69
+ f"https://research.splunk.com/{detection_type}/{detection_id}/"
70
+ )
53
71
 
54
- layer_json["showTacticRowBackground"] = True
55
- layer_json["tacticRowBackground"] = "#dddddd"
56
- layer_json["sorting"] = 3
72
+ links.append({"label": detection_name, "url": research_url})
57
73
 
58
- for technique_id in mitre_techniques.keys():
59
74
  layer_technique = {
60
75
  "techniqueID": technique_id,
61
- "score": mitre_techniques[technique_id]["score"],
62
- "comment": "\n\n".join(mitre_techniques[technique_id]["file_paths"]),
76
+ "score": data["score"],
77
+ "enabled": True,
78
+ "links": links,
63
79
  }
64
80
  layer_json["techniques"].append(layer_technique)
65
81
 
@@ -93,6 +93,10 @@ class ConfOutput:
93
93
 
94
94
  return written_files
95
95
 
96
+ # TODO (#339): we could have a discrepancy between detections tested and those delivered
97
+ # based on the jinja2 template
98
+ # {% if (detection.type == 'TTP' or detection.type == 'Anomaly' or
99
+ # detection.type == 'Hunting' or detection.type == 'Correlation') %}
96
100
  def writeDetections(self, objects: list[Detection]) -> set[pathlib.Path]:
97
101
  written_files: set[pathlib.Path] = set()
98
102
  for output_app_path, template_name in [
@@ -211,7 +215,11 @@ class ConfOutput:
211
215
  # even though the MLModel info was intentionally not written to the
212
216
  # transforms.conf file as noted above.
213
217
  if isinstance(lookup, FileBackedLookup):
214
- shutil.copy(lookup.filename, lookup_folder / lookup.app_filename.name)
218
+ with (
219
+ open(lookup_folder / lookup.app_filename.name, "w") as output_file,
220
+ lookup.content_file_handle as output,
221
+ ):
222
+ output_file.write(output.read())
215
223
  return written_files
216
224
 
217
225
  def writeMacros(self, objects: list[Macro]) -> set[pathlib.Path]:
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from io import StringIO
5
+ from typing import TYPE_CHECKING, List
6
+
7
+ if TYPE_CHECKING:
8
+ from contentctl.input.director import DirectorOutputDto
9
+
10
+ from contentctl.objects.config import CustomApp
11
+ from contentctl.objects.data_source import DataSource
12
+
13
+
14
+ class RuntimeCsvWriter:
15
+ @staticmethod
16
+ def generateDeprecationCSVContent(
17
+ director: DirectorOutputDto, app: CustomApp
18
+ ) -> str:
19
+ with StringIO() as output_buffer:
20
+ fieldNames = [
21
+ "Name",
22
+ "Content Type",
23
+ "Removed in Version",
24
+ "Reason",
25
+ "Replacement Content",
26
+ "Replacement Content Link",
27
+ ]
28
+
29
+ writer = csv.DictWriter(output_buffer, fieldnames=fieldNames)
30
+ writer.writeheader()
31
+
32
+ for content in director.name_to_content_map.values():
33
+ if content.deprecation_info is not None:
34
+ try:
35
+ writer.writerow(
36
+ {
37
+ "Name": content.deprecation_info.contentType.static_get_conf_stanza_name(
38
+ content.name, app
39
+ ),
40
+ "Content Type": content.deprecation_info.contentType.__name__,
41
+ "Removed in Version": content.deprecation_info.removed_in_version,
42
+ "Reason": content.deprecation_info.reason,
43
+ "Replacement Content": "\n".join(
44
+ [
45
+ c.name
46
+ for c in content.deprecation_info.replacement_content
47
+ ]
48
+ )
49
+ or "No Replacement Content Available",
50
+ "Replacement Content Link": "\n".join(
51
+ [
52
+ str(c.researchSiteLink)
53
+ for c in content.deprecation_info.replacement_content
54
+ ]
55
+ )
56
+ or "No Content Link Available",
57
+ }
58
+ )
59
+ except Exception as e:
60
+ print(e)
61
+ import code
62
+
63
+ code.interact(local=locals())
64
+ return output_buffer.getvalue()
65
+
66
+ @staticmethod
67
+ def generateDatasourceCSVContent(
68
+ data_source_objects: List[DataSource],
69
+ ) -> str:
70
+ with StringIO() as output_buffer:
71
+ writer = csv.writer(output_buffer)
72
+ # Write the header
73
+ writer.writerow(
74
+ [
75
+ "name",
76
+ "id",
77
+ "author",
78
+ "source",
79
+ "sourcetype",
80
+ "separator",
81
+ "supported_TA_name",
82
+ "supported_TA_version",
83
+ "supported_TA_url",
84
+ "description",
85
+ ]
86
+ )
87
+ # Write the data
88
+ for data_source in data_source_objects:
89
+ if len(data_source.supported_TA) > 0:
90
+ supported_TA_name = data_source.supported_TA[0].name
91
+ supported_TA_version = data_source.supported_TA[0].version
92
+ supported_TA_url = data_source.supported_TA[0].url or ""
93
+ else:
94
+ supported_TA_name = ""
95
+ supported_TA_version = ""
96
+ supported_TA_url = ""
97
+ writer.writerow(
98
+ [
99
+ data_source.name,
100
+ data_source.id,
101
+ data_source.author,
102
+ data_source.source,
103
+ data_source.sourcetype,
104
+ data_source.separator,
105
+ supported_TA_name,
106
+ supported_TA_version,
107
+ supported_TA_url,
108
+ data_source.description,
109
+ ]
110
+ )
111
+ return output_buffer.getvalue()
@@ -1,10 +1,9 @@
1
1
  import pathlib
2
- from typing import List, Any
2
+ from typing import Any, List
3
3
 
4
- from contentctl.objects.enums import SecurityContentType
5
- from contentctl.output.jinja_writer import JinjaWriter
6
- from contentctl.objects.enums import DetectionStatus
7
4
  from contentctl.objects.detection import Detection
5
+ from contentctl.objects.enums import ContentStatus, SecurityContentType
6
+ from contentctl.output.jinja_writer import JinjaWriter
8
7
 
9
8
 
10
9
  class SvgOutput:
@@ -49,7 +48,7 @@ class SvgOutput:
49
48
  [
50
49
  detection
51
50
  for detection in detections
52
- if detection.status == DetectionStatus.production
51
+ if detection.status == ContentStatus.production
53
52
  ],
54
53
  )
55
54
  # deprecated_dict = self.get_badge_dict("Deprecated", detections, [detection for detection in detections if detection.status == DetectionStatus.deprecated])
@@ -65,14 +65,10 @@ action.notable.param.nes_fields = {{ detection.nes_fields }}
65
65
  action.notable.param.rule_description = {{ detection.deployment.alert_action.notable.rule_description | custom_jinja2_enrichment_filter(detection) | escapeNewlines()}}
66
66
  action.notable.param.rule_title = {% if detection.type | lower == "correlation" %}RBA: {{ detection.deployment.alert_action.notable.rule_title | custom_jinja2_enrichment_filter(detection) }}{% else %}{{ detection.deployment.alert_action.notable.rule_title | custom_jinja2_enrichment_filter(detection) }}{% endif +%}
67
67
  action.notable.param.security_domain = {{ detection.tags.security_domain }}
68
- {% if detection.rba %}
69
- action.notable.param.severity = {{ detection.rba.severity }}
70
- {% else %}
71
- {# Correlations do not have detection.rba defined, but should get a default severity #}
72
- action.notable.param.severity = high
73
- {% endif %}
68
+ action.notable.param.severity = {{ detection.severity }}
74
69
  {% endif %}
75
70
  {% if detection.deployment.alert_action.email %}
71
+ action.email = 1
76
72
  action.email.subject.alert = {{ detection.deployment.alert_action.email.subject | custom_jinja2_enrichment_filter(detection) | escapeNewlines() }}
77
73
  action.email.to = {{ detection.deployment.alert_action.email.to }}
78
74
  action.email.message.alert = {{ detection.deployment.alert_action.email.message | custom_jinja2_enrichment_filter(detection) | escapeNewlines() }}
@@ -7,8 +7,8 @@ filename = {{ lookup.app_filename.name }}
7
7
  collection = {{ lookup.collection }}
8
8
  external_type = kvstore
9
9
  {% endif %}
10
- {% if lookup.default_match is defined and lookup.default_match != None %}
11
- default_match = {{ lookup.default_match | lower }}
10
+ {% if lookup.default_match != '' %}
11
+ default_match = {{ lookup.default_match }}
12
12
  {% endif %}
13
13
  {% if lookup.case_sensitive_match is defined and lookup.case_sensitive_match != None %}
14
14
  case_sensitive_match = {{ lookup.case_sensitive_match | lower }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: contentctl
3
- Version: 5.1.0
3
+ Version: 5.3.0
4
4
  Summary: Splunk Content Control Tool
5
5
  License: Apache 2.0
6
6
  Author: STRT
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Dist: Jinja2 (>=3.1.4,<4.0.0)
15
15
  Requires-Dist: PyYAML (>=6.0.2,<7.0.0)
16
- Requires-Dist: attackcti (>=0.4.0,<0.5.0)
16
+ Requires-Dist: attackcti (>=0.5.4,<0.6)
17
17
  Requires-Dist: bottle (>=0.12.25,<0.14.0)
18
18
  Requires-Dist: docker (>=7.1.0,<8.0.0)
19
19
  Requires-Dist: gitpython (>=3.1.43,<4.0.0)
@@ -22,8 +22,9 @@ Requires-Dist: pydantic (>=2.9.2,<2.10.0)
22
22
  Requires-Dist: pygit2 (>=1.15.1,<2.0.0)
23
23
  Requires-Dist: questionary (>=2.0.1,<3.0.0)
24
24
  Requires-Dist: requests (>=2.32.3,<2.33.0)
25
+ Requires-Dist: rich (>=14.0.0,<15.0.0)
25
26
  Requires-Dist: semantic-version (>=2.10.0,<3.0.0)
26
- Requires-Dist: setuptools (>=69.5.1,<76.0.0)
27
+ Requires-Dist: setuptools (>=69.5.1,<79.0.0)
27
28
  Requires-Dist: splunk-sdk (>=2.0.2,<3.0.0)
28
29
  Requires-Dist: tqdm (>=4.66.5,<5.0.0)
29
30
  Requires-Dist: tyro (>=0.9.2,<0.10.0)