contentctl-ng 0.1.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.
- contentctl_ng/__init__.py +10 -0
- contentctl_ng/__main__.py +10 -0
- contentctl_ng/build.py +635 -0
- contentctl_ng/cli.py +143 -0
- contentctl_ng/contentctl_legacy_ported/__init__.py +4 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/DetectionTestingManager.py +309 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/GitService.py +388 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +1872 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/infrastructures/DetectionTestingInfrastructureServer.py +39 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/progress_bar.py +122 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/views/DetectionTestingView.py +266 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/views/DetectionTestingViewCLI.py +114 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/views/DetectionTestingViewFile.py +89 -0
- contentctl_ng/contentctl_legacy_ported/actions/detection_testing/views/DetectionTestingViewWeb.py +210 -0
- contentctl_ng/contentctl_legacy_ported/actions/test.py +186 -0
- contentctl_ng/contentctl_legacy_ported/contentctl.py +266 -0
- contentctl_ng/contentctl_legacy_ported/helper/splunk_app.py +503 -0
- contentctl_ng/contentctl_legacy_ported/helper/utils.py +679 -0
- contentctl_ng/contentctl_legacy_ported/objects/TestOnlyDetection.py +475 -0
- contentctl_ng/contentctl_legacy_ported/objects/annotated_types.py +14 -0
- contentctl_ng/contentctl_legacy_ported/objects/base_security_event.py +35 -0
- contentctl_ng/contentctl_legacy_ported/objects/base_test.py +72 -0
- contentctl_ng/contentctl_legacy_ported/objects/base_test_result.py +183 -0
- contentctl_ng/contentctl_legacy_ported/objects/config.py +1902 -0
- contentctl_ng/contentctl_legacy_ported/objects/constants.py +155 -0
- contentctl_ng/contentctl_legacy_ported/objects/content_versioning_service.py +698 -0
- contentctl_ng/contentctl_legacy_ported/objects/correlation_search.py +1361 -0
- contentctl_ng/contentctl_legacy_ported/objects/detection_metadata.py +91 -0
- contentctl_ng/contentctl_legacy_ported/objects/enums.py +519 -0
- contentctl_ng/contentctl_legacy_ported/objects/errors.py +420 -0
- contentctl_ng/contentctl_legacy_ported/objects/integration_test.py +58 -0
- contentctl_ng/contentctl_legacy_ported/objects/integration_test_result.py +18 -0
- contentctl_ng/contentctl_legacy_ported/objects/manual_test.py +47 -0
- contentctl_ng/contentctl_legacy_ported/objects/manual_test_result.py +17 -0
- contentctl_ng/contentctl_legacy_ported/objects/mitre_attack_enrichment.py +179 -0
- contentctl_ng/contentctl_legacy_ported/objects/notable_action.py +53 -0
- contentctl_ng/contentctl_legacy_ported/objects/notable_event.py +27 -0
- contentctl_ng/contentctl_legacy_ported/objects/rba.py +190 -0
- contentctl_ng/contentctl_legacy_ported/objects/risk_analysis_action.py +114 -0
- contentctl_ng/contentctl_legacy_ported/objects/risk_event.py +311 -0
- contentctl_ng/contentctl_legacy_ported/objects/risk_object.py +31 -0
- contentctl_ng/contentctl_legacy_ported/objects/savedsearch_stanza.py +214 -0
- contentctl_ng/contentctl_legacy_ported/objects/savedsearches_conf.py +228 -0
- contentctl_ng/contentctl_legacy_ported/objects/test_attack_data.py +72 -0
- contentctl_ng/contentctl_legacy_ported/objects/test_group.py +102 -0
- contentctl_ng/contentctl_legacy_ported/objects/threat_object.py +28 -0
- contentctl_ng/contentctl_legacy_ported/objects/unit_test.py +49 -0
- contentctl_ng/contentctl_legacy_ported/objects/unit_test_baseline.py +22 -0
- contentctl_ng/contentctl_legacy_ported/objects/unit_test_result.py +97 -0
- contentctl_ng/contentctl_legacy_ported/output/yml_writer.py +93 -0
- contentctl_ng/demo_main.py +22 -0
- contentctl_ng/generate_erdantic_docs.py +24 -0
- contentctl_ng/inspect_.py +459 -0
- contentctl_ng/install.py +994 -0
- contentctl_ng/objects/app.py +356 -0
- contentctl_ng/objects/atomic_guid.py +227 -0
- contentctl_ng/objects/conf.py +879 -0
- contentctl_ng/objects/content.py +951 -0
- contentctl_ng/objects/dashboard.py +157 -0
- contentctl_ng/objects/data_source.py +423 -0
- contentctl_ng/objects/lookup.py +326 -0
- contentctl_ng/objects/macro.py +197 -0
- contentctl_ng/objects/mitre_enrichment.py +236 -0
- contentctl_ng/objects/object_parser.py +416 -0
- contentctl_ng/objects/schedule.py +206 -0
- contentctl_ng/objects/searches/baseline.py +86 -0
- contentctl_ng/objects/searches/detection.py +910 -0
- contentctl_ng/objects/searches/search.py +639 -0
- contentctl_ng/objects/splstring.py +303 -0
- contentctl_ng/objects/story.py +232 -0
- contentctl_ng/port.py +65 -0
- contentctl_ng/py.typed +0 -0
- contentctl_ng/release_notes.py +456 -0
- contentctl_ng/resources/datamodels_cim.conf +340 -0
- contentctl_ng/resources/detection_count_svg.template +18 -0
- contentctl_ng/resources/detection_coverage_svg.template +16 -0
- contentctl_ng/resources/install.yml +34 -0
- contentctl_ng/resources/port_build_template.yml +50 -0
- contentctl_ng/settings.py +596 -0
- contentctl_ng/test.py +329 -0
- contentctl_ng/utilities/common_io.py +461 -0
- contentctl_ng/utilities/port_yml_file.py +997 -0
- contentctl_ng/utilities/reporting_tools.py +333 -0
- contentctl_ng/version.py +73 -0
- contentctl_ng-0.1.0.dist-info/METADATA +26 -0
- contentctl_ng-0.1.0.dist-info/RECORD +88 -0
- contentctl_ng-0.1.0.dist-info/WHEEL +4 -0
- contentctl_ng-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Allows contentctl_ng to be used as a package."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
TQDM_DISABLE_ENV_VAR = "TQDM_DISABLE"
|
|
7
|
+
|
|
8
|
+
if not sys.stderr.isatty() and TQDM_DISABLE_ENV_VAR not in os.environ:
|
|
9
|
+
# If stderr is not a terminal and the envvar is not already set, disable tqdm progress bars to avoid messy output
|
|
10
|
+
os.environ[TQDM_DISABLE_ENV_VAR] = "true"
|
contentctl_ng/build.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""Very simple demo that will parse security content objects."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
import tarfile
|
|
6
|
+
from csv import QUOTE_ALL, DictWriter
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from pydantic_settings import SettingsConfigDict
|
|
12
|
+
|
|
13
|
+
import contentctl_ng.utilities.reporting_tools as reporting_tools
|
|
14
|
+
from contentctl_ng.objects.conf import (
|
|
15
|
+
AppDotConf,
|
|
16
|
+
ConfFile,
|
|
17
|
+
ContentVersionDotConf,
|
|
18
|
+
DistSearchDotConf,
|
|
19
|
+
ServerDotConf,
|
|
20
|
+
)
|
|
21
|
+
from contentctl_ng.objects.content import DeprecatableSecurityContent, SecurityContent
|
|
22
|
+
from contentctl_ng.objects.lookup import CSVLookup
|
|
23
|
+
from contentctl_ng.objects.object_parser import AppFactory
|
|
24
|
+
from contentctl_ng.settings import ContentSettings
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_app_template(app_factory: AppFactory, built_app_directory: Path) -> None:
|
|
30
|
+
"""Write everything in the app template to the build directory.
|
|
31
|
+
|
|
32
|
+
This will combine the app_templates from all of the directories/repos
|
|
33
|
+
that make up the app. Any collisions/attempts to overwrite
|
|
34
|
+
files in app_template directories will result in an exception.
|
|
35
|
+
For instance, if directory 1 contains:
|
|
36
|
+
security_content_github/app_template/default.meta
|
|
37
|
+
AND directory 2 contains:
|
|
38
|
+
security_content_gitlab/app_template/default.meta
|
|
39
|
+
then an exception will be raised.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
app_factory: The app factory to use to generate the app template.
|
|
43
|
+
built_app_directory: The directory to write the app template to.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
None - but as a result the app template is written to the built_app_directory
|
|
47
|
+
"""
|
|
48
|
+
for directory in app_factory.app._content_directories: # pyright: ignore[reportPrivateUsage]
|
|
49
|
+
app_template_directory = directory.path / "app_template"
|
|
50
|
+
|
|
51
|
+
for templated_file in app_template_directory.rglob("*"):
|
|
52
|
+
if not templated_file.is_file():
|
|
53
|
+
# Only worry about copying files - do not empty create directory structure.
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
# Get the file relative to the path of the new app
|
|
57
|
+
relative_path = templated_file.relative_to(app_template_directory)
|
|
58
|
+
build_file_path = built_app_directory / relative_path
|
|
59
|
+
if build_file_path.exists():
|
|
60
|
+
raise Exception(
|
|
61
|
+
f"Error writing app template from directory "
|
|
62
|
+
f"{app_template_directory}: File {build_file_path} already exists."
|
|
63
|
+
"Please remove it before running this script."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# The file does not exist, so copy it.
|
|
67
|
+
# We must make sure that the target folder exists first
|
|
68
|
+
build_file_path.parent.mkdir(exist_ok=True, parents=True)
|
|
69
|
+
templated_file.copy(build_file_path)
|
|
70
|
+
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def write_conf_files(app_factory: AppFactory, built_app_directory: Path) -> None:
|
|
75
|
+
"""Generate all of the conf files required for the app.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
app_factory: The app factory to use to generate the conf files.
|
|
79
|
+
built_app_directory: The directory to write the conf files to.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
None - but as a result the app is built in the built_app_directory
|
|
83
|
+
"""
|
|
84
|
+
conf_directory = built_app_directory / "default"
|
|
85
|
+
# It's okay if this exists becuase it may have been created
|
|
86
|
+
# via the app_template. However, it's also okay if
|
|
87
|
+
# it DOESN'T exist.
|
|
88
|
+
conf_directory.mkdir(exist_ok=True, parents=True)
|
|
89
|
+
|
|
90
|
+
# These confs files do not require any special actions or iteration so just emit them.
|
|
91
|
+
in_memory_conf_files: dict[str, ConfFile] = {}
|
|
92
|
+
|
|
93
|
+
# Construct all the file object in memory
|
|
94
|
+
# This is done ONCE, not once per repo directory for the app
|
|
95
|
+
# We constuct these files in memory first so that we can later
|
|
96
|
+
# serialize them all at once.
|
|
97
|
+
|
|
98
|
+
# The following can be constructed without SecurityContent
|
|
99
|
+
for conf_object in (
|
|
100
|
+
AppDotConf,
|
|
101
|
+
DistSearchDotConf,
|
|
102
|
+
ContentVersionDotConf,
|
|
103
|
+
ServerDotConf,
|
|
104
|
+
):
|
|
105
|
+
conf = conf_object(
|
|
106
|
+
directory_path=conf_directory,
|
|
107
|
+
app_settings=app_factory.app._shared_app_settings, # pyright: ignore[reportPrivateUsage]
|
|
108
|
+
)
|
|
109
|
+
in_memory_conf_files[conf.file_name] = conf
|
|
110
|
+
|
|
111
|
+
# Construct a list so that we do not need to duplicate the serialization code.
|
|
112
|
+
# First we have the filename. Then, we have a list of content along with the name
|
|
113
|
+
# of its serialization method. For instance the following object is annotated
|
|
114
|
+
# for informational purposes:
|
|
115
|
+
# Each tuple in the list is in the following format:
|
|
116
|
+
# tuple[0]: str - the name of the conf file to serialize to
|
|
117
|
+
# tuple[1]: list[list[object, str]] - a list of objects and their serialization methods
|
|
118
|
+
# tuple[1][0]: list[object, str] - a list of objects and their serialization methods
|
|
119
|
+
|
|
120
|
+
conf_file_to_content_mapping: list[tuple[str, list[tuple[Any, str]]]] = [
|
|
121
|
+
("macros.conf", [(app_factory.macros, "emit_ConfStanza")]),
|
|
122
|
+
(
|
|
123
|
+
"transforms.conf",
|
|
124
|
+
[
|
|
125
|
+
(
|
|
126
|
+
app_factory.csv_lookups + app_factory.kvstore_lookups,
|
|
127
|
+
"emit_ConfStanza",
|
|
128
|
+
)
|
|
129
|
+
],
|
|
130
|
+
),
|
|
131
|
+
# analyticstories.conf contains two different types of content:
|
|
132
|
+
# stories and detections in different formats.
|
|
133
|
+
# We need to serialize them differently.
|
|
134
|
+
(
|
|
135
|
+
"analyticstories.conf",
|
|
136
|
+
[
|
|
137
|
+
(app_factory.stories, "emit_ConfStanza"),
|
|
138
|
+
(app_factory.detections, "emit_ConfStanza_analyticstories"),
|
|
139
|
+
],
|
|
140
|
+
),
|
|
141
|
+
(
|
|
142
|
+
"collections.conf",
|
|
143
|
+
[(app_factory.kvstore_lookups, "emit_ConfStanza_collections")],
|
|
144
|
+
),
|
|
145
|
+
# savedsearches contains two different types of content:
|
|
146
|
+
# detections and baselines in different formats.
|
|
147
|
+
# We need to serialize them differently.
|
|
148
|
+
(
|
|
149
|
+
"savedsearches.conf",
|
|
150
|
+
[
|
|
151
|
+
(app_factory.detections, "emit_ConfStanza_savedsearches"),
|
|
152
|
+
(app_factory.baselines, "emit_ConfStanza_savedsearches"),
|
|
153
|
+
],
|
|
154
|
+
),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
# Iterate over all the
|
|
158
|
+
for conf_file_name, conf_file_contents in conf_file_to_content_mapping:
|
|
159
|
+
conf = ConfFile(
|
|
160
|
+
file_name=conf_file_name,
|
|
161
|
+
directory_path=conf_directory,
|
|
162
|
+
app_settings=app_factory.app._shared_app_settings, # pyright: ignore[reportPrivateUsage]
|
|
163
|
+
)
|
|
164
|
+
if in_memory_conf_files.get(conf.file_name, None) is not None:
|
|
165
|
+
raise Exception(
|
|
166
|
+
f"Conf file {conf.file_name} already exists in the in_memory_conf_files dictionary. It must not be recreated."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
in_memory_conf_files[conf.file_name] = conf
|
|
170
|
+
|
|
171
|
+
# Iterate over all the types of content that must go into a given conf file
|
|
172
|
+
for content_object, serialization_method_name in conf_file_contents:
|
|
173
|
+
# Iterate over all the objects of a given type
|
|
174
|
+
for obj in content_object:
|
|
175
|
+
# Add this object's stanza to the conf file.
|
|
176
|
+
conf.add_stanza(getattr(obj, serialization_method_name)())
|
|
177
|
+
|
|
178
|
+
# write out all the file objects. For the same of consistency, we sort them by file name.
|
|
179
|
+
for conf_file in sorted(in_memory_conf_files.values(), key=lambda x: x.file_name):
|
|
180
|
+
conf_file.serialize_to_file()
|
|
181
|
+
|
|
182
|
+
# NOTE: this will be removed once FBDs have proper support in contentctl-ng
|
|
183
|
+
# After writing savedsearches.conf, append static FBD content if it exists
|
|
184
|
+
append_savedsearches_fbd_conf(app_factory, conf_directory)
|
|
185
|
+
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# NOTE: this will be removed once FBDs have proper support in contentctl-ng
|
|
190
|
+
def parse_savedsearches_fbd_conf(fbd_conf_file: Path) -> list[str]:
|
|
191
|
+
"""Parse savedsearches_fbd.conf file and extract stanzas, skipping comments and blank lines.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
fbd_conf_file: Path to the savedsearches_fbd.conf file
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
List of stanza strings (each string is a complete stanza)
|
|
198
|
+
"""
|
|
199
|
+
stanzas: list[str] = []
|
|
200
|
+
current_stanza_lines: list[str] = []
|
|
201
|
+
has_stanza_header = False
|
|
202
|
+
|
|
203
|
+
with open(fbd_conf_file, "r") as f:
|
|
204
|
+
for line in f:
|
|
205
|
+
stripped = line.strip()
|
|
206
|
+
|
|
207
|
+
# Check if this is a stanza header
|
|
208
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
209
|
+
# If we were accumulating a previous stanza, finalize it first
|
|
210
|
+
if has_stanza_header and current_stanza_lines:
|
|
211
|
+
stanzas.append("\n".join(current_stanza_lines))
|
|
212
|
+
current_stanza_lines = []
|
|
213
|
+
|
|
214
|
+
# Start a new stanza
|
|
215
|
+
has_stanza_header = True
|
|
216
|
+
current_stanza_lines.append(line.rstrip())
|
|
217
|
+
|
|
218
|
+
# Skip comment lines
|
|
219
|
+
elif stripped.startswith("#"):
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Blank line - finalize current stanza if we have one
|
|
223
|
+
elif stripped == "":
|
|
224
|
+
if has_stanza_header and current_stanza_lines:
|
|
225
|
+
stanzas.append("\n".join(current_stanza_lines))
|
|
226
|
+
current_stanza_lines = []
|
|
227
|
+
has_stanza_header = False
|
|
228
|
+
|
|
229
|
+
# Non-empty, non-comment line - accumulate if we're in a stanza
|
|
230
|
+
else:
|
|
231
|
+
if has_stanza_header:
|
|
232
|
+
current_stanza_lines.append(line.rstrip())
|
|
233
|
+
|
|
234
|
+
# Handle the last stanza if file doesn't end with a blank line
|
|
235
|
+
if has_stanza_header and current_stanza_lines:
|
|
236
|
+
stanzas.append("\n".join(current_stanza_lines))
|
|
237
|
+
|
|
238
|
+
return stanzas
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# NOTE: this will be removed once FBDs have proper support in contentctl-ng
|
|
242
|
+
def append_savedsearches_fbd_conf(
|
|
243
|
+
app_factory: AppFactory, conf_directory: Path
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Append content from static_configs/savedsearches_fbd.conf to savedsearches.conf.
|
|
246
|
+
|
|
247
|
+
This function reads the savedsearches_fbd.conf file from the content directory's
|
|
248
|
+
static_configs folder, parses it into stanzas, and appends them to the generated
|
|
249
|
+
savedsearches.conf file using a template format. This is done after the normal
|
|
250
|
+
conf generation because FBD searches are static conf content, not generated from
|
|
251
|
+
YML content objects.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
app_factory: The app factory to use to locate the static_configs directory.
|
|
255
|
+
conf_directory: The directory containing the generated conf files.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
None - but as a result the savedsearches_fbd content is appended to savedsearches.conf
|
|
259
|
+
"""
|
|
260
|
+
savedsearches_conf_path = conf_directory / "savedsearches.conf"
|
|
261
|
+
|
|
262
|
+
# Ensure the savedsearches.conf file exists before appending
|
|
263
|
+
if not savedsearches_conf_path.exists():
|
|
264
|
+
logger.warning(
|
|
265
|
+
f"savedsearches.conf does not exist at {savedsearches_conf_path}. "
|
|
266
|
+
"Cannot append savedsearches_fbd.conf content."
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# Get the first content directory (there should be only one after porting)
|
|
271
|
+
content_directory = app_factory.app._content_directories[0] # pyright: ignore[reportPrivateUsage]
|
|
272
|
+
fbd_conf_file = content_directory.path / "static_configs" / "savedsearches_fbd.conf"
|
|
273
|
+
|
|
274
|
+
if not fbd_conf_file.is_file():
|
|
275
|
+
logger.debug(f"No savedsearches_fbd.conf found at {fbd_conf_file}, skipping")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Parse the FBD conf file into stanzas
|
|
279
|
+
fbd_stanzas = parse_savedsearches_fbd_conf(fbd_conf_file)
|
|
280
|
+
|
|
281
|
+
# Only write if there are actual stanzas
|
|
282
|
+
if not fbd_stanzas:
|
|
283
|
+
logger.debug("No FBD stanzas found in savedsearches_fbd.conf, skipping")
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
# Append to savedsearches.conf using template format
|
|
287
|
+
with open(savedsearches_conf_path, "a") as savedsearches_file:
|
|
288
|
+
for fbd_stanza in fbd_stanzas:
|
|
289
|
+
savedsearches_file.write("\n")
|
|
290
|
+
savedsearches_file.write(fbd_stanza)
|
|
291
|
+
savedsearches_file.write("\n")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_directory_to_tar_gz(built_app_directory: Path) -> Path:
|
|
295
|
+
"""Packages the app directory into an appropriate Splunk .tar.gz app archive.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
built_app_directory: The directory containing the root of the built app.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Path: The path to the built app archive in .tar.gz format
|
|
302
|
+
"""
|
|
303
|
+
archive_path = built_app_directory.with_suffix(".tar.gz")
|
|
304
|
+
archive_path_latest = built_app_directory.with_name(
|
|
305
|
+
built_app_directory.name + "-latest"
|
|
306
|
+
).with_suffix(".tar.gz")
|
|
307
|
+
|
|
308
|
+
logger.debug(f"Packaging app at {archive_path}")
|
|
309
|
+
logger.debug(
|
|
310
|
+
f"For legacy compatibility reasons, also packaging app at {archive_path_latest}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# TODO: Should we keep both of these packages? Or, since the dist/
|
|
314
|
+
# Directory is always removed on build and the version is not longer
|
|
315
|
+
# part of the archive name, just emit the package name with no version
|
|
316
|
+
# or -latest on the end?
|
|
317
|
+
for arc_path in [archive_path, archive_path_latest]:
|
|
318
|
+
with tarfile.open(arc_path, "w:gz") as app_archive:
|
|
319
|
+
app_archive.add(
|
|
320
|
+
built_app_directory,
|
|
321
|
+
arcname=built_app_directory.name,
|
|
322
|
+
)
|
|
323
|
+
logger.info(f"Successfully packaged app at {arc_path}")
|
|
324
|
+
|
|
325
|
+
return archive_path
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def write_data_sources_csv(app_factory: AppFactory, scratch_directory: Path) -> None:
|
|
329
|
+
"""Generate a CSV file about data_sources used by the app.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
app_factory: The app factory to use to create the app.
|
|
333
|
+
scratch_directory: The directory to write the supporting files to.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
None - result is that the data_sources csv file is written to the scratch_directory
|
|
337
|
+
"""
|
|
338
|
+
datasource_rows: list[dict[str, str]] = [
|
|
339
|
+
ds.datasource_csv_row for ds in app_factory.data_sources
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
write_dynamic_csv(
|
|
343
|
+
app_factory, scratch_directory, "data_sources", datasource_rows, ["name"]
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def write_deprecaton_info_csv(app_factory: AppFactory, scratch_directory: Path) -> None:
|
|
348
|
+
"""Generate a CSV file with info about deprecated content used by the app.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
app_factory: The app factory to use to create the app.
|
|
352
|
+
scratch_directory: The directory to write the supporting files to.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
None - result is that the deprecation info csv file is written to the scratch_directory.
|
|
356
|
+
"""
|
|
357
|
+
deprecation_info_rows: list[dict[str, str]] = []
|
|
358
|
+
|
|
359
|
+
# Macros and Schedules defined at runtime (e.g. _filter macros or schedules defined in detections)
|
|
360
|
+
# are included in this list of content,
|
|
361
|
+
# but they do not have deprecation_info
|
|
362
|
+
# so it's not an issue. This is one of the
|
|
363
|
+
# reasons we use hasattr rather than
|
|
364
|
+
# content.deprecation_info (field may not exist)
|
|
365
|
+
for content in app_factory.all_content_including_removed:
|
|
366
|
+
if (
|
|
367
|
+
isinstance(content, DeprecatableSecurityContent)
|
|
368
|
+
and content.deprecation_info_csv_row is not None
|
|
369
|
+
):
|
|
370
|
+
deprecation_info_rows.append(content.deprecation_info_csv_row)
|
|
371
|
+
write_dynamic_csv(
|
|
372
|
+
app_factory,
|
|
373
|
+
scratch_directory,
|
|
374
|
+
"deprecation_info",
|
|
375
|
+
deprecation_info_rows,
|
|
376
|
+
["Content Type", "Name"],
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def write_dynamic_csv(
|
|
381
|
+
app_factory: AppFactory,
|
|
382
|
+
scratch_directory: Path,
|
|
383
|
+
# TODO: Convert Consider conversion of Literal to StrEnum
|
|
384
|
+
dynamic_file_type: Literal["data_sources", "deprecation_info"],
|
|
385
|
+
rows: list[dict[str, str]],
|
|
386
|
+
sort_by_keys: list[str] = [],
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Generate a CSV file dynamically for inclusion in the app."""
|
|
389
|
+
# These values are shared by all dynamic files
|
|
390
|
+
model_dict: dict[str, Any] = {
|
|
391
|
+
"author": app_factory.app._shared_app_settings.author, # pyright: ignore[reportPrivateUsage]
|
|
392
|
+
"creation_date": app_factory.app._shared_app_settings._build_time.date(), # pyright: ignore[reportPrivateUsage]
|
|
393
|
+
"modification_date": app_factory.app._shared_app_settings._build_time.date(), # pyright: ignore[reportPrivateUsage]
|
|
394
|
+
"version": 1,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if dynamic_file_type == "data_sources":
|
|
398
|
+
fieldnames = [
|
|
399
|
+
"name",
|
|
400
|
+
"id",
|
|
401
|
+
"author",
|
|
402
|
+
"source",
|
|
403
|
+
"sourcetype",
|
|
404
|
+
"separator",
|
|
405
|
+
"supported_TA_name",
|
|
406
|
+
"supported_TA_version",
|
|
407
|
+
"supported_TA_url",
|
|
408
|
+
"description",
|
|
409
|
+
]
|
|
410
|
+
model_dict.update(
|
|
411
|
+
{
|
|
412
|
+
"name": "data_sources",
|
|
413
|
+
"id": UUID(
|
|
414
|
+
"b45c1403-6e09-47b0-824f-cf6e44f15ac8"
|
|
415
|
+
), # statically defined so it does not change
|
|
416
|
+
"description": "A lookup file that contains the data source objects for detections.",
|
|
417
|
+
"case_sensitive_match": False,
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
elif dynamic_file_type == "deprecation_info":
|
|
422
|
+
fieldnames = [
|
|
423
|
+
"Name",
|
|
424
|
+
"Content Type",
|
|
425
|
+
"Removed in Version",
|
|
426
|
+
"Reason",
|
|
427
|
+
"Replacement Content",
|
|
428
|
+
"Replacement Content Link",
|
|
429
|
+
]
|
|
430
|
+
model_dict.update(
|
|
431
|
+
{
|
|
432
|
+
"name": "deprecation_info",
|
|
433
|
+
"id": UUID(
|
|
434
|
+
"99262bf2-9606-4b52-b377-c96713527b35"
|
|
435
|
+
), # statically defined so it does not change
|
|
436
|
+
"description": "A lookup file that contains information about content that has been deprecated or removed from the app.",
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
else:
|
|
441
|
+
# Based on typing, we should never get here. But we keep this
|
|
442
|
+
# to be safe against future changes.
|
|
443
|
+
raise ValueError(f"Unknown dynamic file type: {dynamic_file_type}")
|
|
444
|
+
|
|
445
|
+
fake_yml_file_path = (scratch_directory / str(model_dict["name"])).with_suffix(
|
|
446
|
+
".yml"
|
|
447
|
+
)
|
|
448
|
+
output_file_name = fake_yml_file_path.with_suffix(".csv")
|
|
449
|
+
|
|
450
|
+
# Generate the CSV File
|
|
451
|
+
# Due to how validation works for CSV Lookups Supporting CSV File,
|
|
452
|
+
# we MUST generate this first
|
|
453
|
+
# before we can create the CSVLookup object.
|
|
454
|
+
with open(
|
|
455
|
+
output_file_name,
|
|
456
|
+
"w",
|
|
457
|
+
newline="",
|
|
458
|
+
) as f:
|
|
459
|
+
writer = DictWriter(
|
|
460
|
+
f,
|
|
461
|
+
fieldnames=fieldnames,
|
|
462
|
+
quoting=QUOTE_ALL,
|
|
463
|
+
extrasaction="raise",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Write the header with titles for all the columns
|
|
467
|
+
writer.writeheader()
|
|
468
|
+
for row in sorted(rows, key=lambda x: tuple(x[k] for k in sort_by_keys)):
|
|
469
|
+
writer.writerow(row)
|
|
470
|
+
|
|
471
|
+
# Now that the CSV exists, we can create the CSVLookup object.
|
|
472
|
+
|
|
473
|
+
# TODO: The fact that _content_directories[0] is used is arbitrary.
|
|
474
|
+
# The constructor for a SecurityContent objects NEEDS a _content_directory
|
|
475
|
+
# in its context and, at this time:
|
|
476
|
+
# - all apps use the same settings
|
|
477
|
+
# - the Root app does not have its own ContentDirectory
|
|
478
|
+
# - there will always be at least one ContentDirectory (index 0)
|
|
479
|
+
# Should we ever introduce supporting multiple different build.yml and
|
|
480
|
+
# CustomAppSettings objects, we should revisit this choice.
|
|
481
|
+
# TODO: Consider whether these files should require a "content_directory" at
|
|
482
|
+
# all, or whether there should be a better approach for these types of runtime files.
|
|
483
|
+
context: dict[str, Any] = {
|
|
484
|
+
"_path": fake_yml_file_path,
|
|
485
|
+
# TODO: Very specifically, it is structurally and semantically "incorrect" to say that
|
|
486
|
+
# this content belongs to app_factory.app._content_directories[0] (which likely represents a repo,
|
|
487
|
+
# such as security_content_github or security_content_gitlab).
|
|
488
|
+
# It don't. It belongs at the "app" level rather than at the ContentDirectory level and
|
|
489
|
+
# is NOT backed a YML or CSV file that exists in a contentDirectory.
|
|
490
|
+
# However, we do not currently support the idea of "app-level content" rather than
|
|
491
|
+
# "ContentDirectory-level content". Take the requirement to build (and possibly track)
|
|
492
|
+
# "app-level content" in a meaningful way, and in addition to "ContentDirectory-level content",
|
|
493
|
+
# in future work.
|
|
494
|
+
"_content_dir": app_factory.app._content_directories[0], # pyright: ignore[reportPrivateUsage]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
dynamic_lookup = CSVLookup.model_validate(
|
|
498
|
+
model_dict,
|
|
499
|
+
context=context,
|
|
500
|
+
)
|
|
501
|
+
app_factory.csv_lookups.append(dynamic_lookup)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def write_supporting_files(
|
|
505
|
+
app_factory: AppFactory, output_file_directory: Path
|
|
506
|
+
) -> None:
|
|
507
|
+
"""Some objects, such as Dashboards and CSVLookups, require additional files to be written to the app.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
app_factory: The app factory to use to create the app.
|
|
511
|
+
output_file_directory: The directory to write the supporting files to.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
None - result is that supporting files are written to the output_file_directory
|
|
515
|
+
in the appropriate locations.
|
|
516
|
+
"""
|
|
517
|
+
# Do not include removed content, since we shouldn't write supporting files for it.
|
|
518
|
+
for content_object in app_factory.all_content:
|
|
519
|
+
# We must check if the object is a SecurityContent object
|
|
520
|
+
# since this list also includes things like Macro
|
|
521
|
+
# and Schedule which MAY not SecurityContent objects.
|
|
522
|
+
# We could consider using a hasattr(content_object, "_supporting_file")
|
|
523
|
+
# instead, but I think isinstance is more explicit.
|
|
524
|
+
if (
|
|
525
|
+
isinstance(content_object, SecurityContent) # pyright: ignore[reportUnnecessaryIsInstance] - added todo to app_factory.all_content to fix typing
|
|
526
|
+
and content_object._supporting_file # pyright: ignore[reportPrivateUsage]
|
|
527
|
+
):
|
|
528
|
+
content_object._supporting_file.write_to_file(output_file_directory) # pyright: ignore[reportPrivateUsage]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def build_app_to_directory(app_factory: AppFactory, build_directory: Path) -> Path:
|
|
532
|
+
"""Creates the entire app structure, including static files, conf files, and tar.gz archive.
|
|
533
|
+
|
|
534
|
+
The build directory MUST not exist when we start building.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
app_factory: The app factory to use to create the app.
|
|
538
|
+
build_directory: The directory to build the app to.
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
Path: The path to the built app archive
|
|
542
|
+
"""
|
|
543
|
+
if build_directory.exists():
|
|
544
|
+
# during development, we want to make sure we don't accidentally overwrite a directory
|
|
545
|
+
# that we didn't mean to. So let's make the deletion of the build directory something
|
|
546
|
+
# that requires explicit confirmation.
|
|
547
|
+
# This will be removed in the future.
|
|
548
|
+
|
|
549
|
+
logger.info(f"Deleting existing build directory {build_directory}")
|
|
550
|
+
shutil.rmtree(build_directory)
|
|
551
|
+
|
|
552
|
+
# Now make the directory which will actually contain the app
|
|
553
|
+
output_file_directory = build_directory / app_factory.app._shared_app_settings.id # pyright: ignore[reportPrivateUsage]
|
|
554
|
+
|
|
555
|
+
# TODO - Writing these files to an arbitrary scratch_directory
|
|
556
|
+
# is likely not a good choice. We should consider whether these
|
|
557
|
+
# files must captured during a build process as artifacts and where
|
|
558
|
+
# to dump them in a more scalable and appropriate manner.
|
|
559
|
+
# Make a scratch directory for files that are dynamically generated
|
|
560
|
+
# during runtime. At this time, this is only two files/pieces of content:
|
|
561
|
+
|
|
562
|
+
# - data_sources CSV and YML files
|
|
563
|
+
# - deprecation_info CSV and YML files
|
|
564
|
+
scratch_directory = build_directory / "scratch"
|
|
565
|
+
scratch_directory.mkdir(exist_ok=False, parents=True)
|
|
566
|
+
|
|
567
|
+
output_file_directory.mkdir(exist_ok=False, parents=True)
|
|
568
|
+
|
|
569
|
+
# write out all the components of the app
|
|
570
|
+
write_app_template(app_factory, output_file_directory)
|
|
571
|
+
write_data_sources_csv(app_factory, scratch_directory)
|
|
572
|
+
write_deprecaton_info_csv(app_factory, scratch_directory)
|
|
573
|
+
write_conf_files(app_factory, output_file_directory)
|
|
574
|
+
write_supporting_files(app_factory, output_file_directory)
|
|
575
|
+
|
|
576
|
+
logger.debug(
|
|
577
|
+
"MAKE SURE THAT THE MITRE CSV FILE HAS BEEN ADDED AS A YML AND "
|
|
578
|
+
"CSV INTO THE LOOKUPS DIRECTORY. IT CURRENTLY DOES NOT EXIST "
|
|
579
|
+
"AS STANDALONE CONTENT"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
logger.info(f"Successfully built app at {output_file_directory}")
|
|
583
|
+
return output_file_directory
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def build_reporting_artifacts(app_factory: AppFactory, build_directory: Path) -> None:
|
|
587
|
+
"""Builds reporting artifacts for the app.
|
|
588
|
+
|
|
589
|
+
This includes a number of SVG badges and the mitre attack json mapping file which
|
|
590
|
+
will power https://mitremap.splunkresearch.com/
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
app_factory (AppFactory): The app factory to use for building the app.
|
|
594
|
+
build_directory (Path): The directory to build the app in.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
None - but as a result the attack nav JSON file and SVG badges are created.
|
|
598
|
+
"""
|
|
599
|
+
reporting_output_path = build_directory / "reporting"
|
|
600
|
+
try:
|
|
601
|
+
reporting_output_path.mkdir(parents=True, exist_ok=False)
|
|
602
|
+
logger.debug(f"Created reporting directory at {reporting_output_path}")
|
|
603
|
+
except FileExistsError:
|
|
604
|
+
raise ValueError(
|
|
605
|
+
f"Reporting output directory already exists: '{reporting_output_path}'"
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Read SVG Templates
|
|
609
|
+
# That these are just strings with format placeholders which we
|
|
610
|
+
# will update with the appropriate values
|
|
611
|
+
|
|
612
|
+
reporting_tools.build_svg_badges(app_factory.detections, reporting_output_path)
|
|
613
|
+
reporting_tools.build_mitre_attack_nav_json(
|
|
614
|
+
app_factory.detections, reporting_output_path
|
|
615
|
+
)
|
|
616
|
+
logger.info(f"Successfully wrote reporting information to {reporting_output_path}")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class Builder(ContentSettings):
|
|
620
|
+
"""Object that builds security content into a distributable format.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
ContentSettings (ContentSettings): Settings for loading content.
|
|
624
|
+
|
|
625
|
+
"""
|
|
626
|
+
|
|
627
|
+
model_config = SettingsConfigDict(
|
|
628
|
+
env_file=".env", extra="ignore", populate_by_name=True
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
def run(self) -> None:
|
|
632
|
+
"""Builds the security content from content_dirs into the build output directory."""
|
|
633
|
+
build_path = build_app_to_directory(self.content, self.build_output_dir)
|
|
634
|
+
build_directory_to_tar_gz(build_path)
|
|
635
|
+
build_reporting_artifacts(self.content, self.build_output_dir)
|