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.
Files changed (88) hide show
  1. contentctl_ng/__init__.py +10 -0
  2. contentctl_ng/__main__.py +10 -0
  3. contentctl_ng/build.py +635 -0
  4. contentctl_ng/cli.py +143 -0
  5. contentctl_ng/contentctl_legacy_ported/__init__.py +4 -0
  6. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/DetectionTestingManager.py +309 -0
  7. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/GitService.py +388 -0
  8. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +1872 -0
  9. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/infrastructures/DetectionTestingInfrastructureServer.py +39 -0
  10. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/progress_bar.py +122 -0
  11. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/views/DetectionTestingView.py +266 -0
  12. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/views/DetectionTestingViewCLI.py +114 -0
  13. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/views/DetectionTestingViewFile.py +89 -0
  14. contentctl_ng/contentctl_legacy_ported/actions/detection_testing/views/DetectionTestingViewWeb.py +210 -0
  15. contentctl_ng/contentctl_legacy_ported/actions/test.py +186 -0
  16. contentctl_ng/contentctl_legacy_ported/contentctl.py +266 -0
  17. contentctl_ng/contentctl_legacy_ported/helper/splunk_app.py +503 -0
  18. contentctl_ng/contentctl_legacy_ported/helper/utils.py +679 -0
  19. contentctl_ng/contentctl_legacy_ported/objects/TestOnlyDetection.py +475 -0
  20. contentctl_ng/contentctl_legacy_ported/objects/annotated_types.py +14 -0
  21. contentctl_ng/contentctl_legacy_ported/objects/base_security_event.py +35 -0
  22. contentctl_ng/contentctl_legacy_ported/objects/base_test.py +72 -0
  23. contentctl_ng/contentctl_legacy_ported/objects/base_test_result.py +183 -0
  24. contentctl_ng/contentctl_legacy_ported/objects/config.py +1902 -0
  25. contentctl_ng/contentctl_legacy_ported/objects/constants.py +155 -0
  26. contentctl_ng/contentctl_legacy_ported/objects/content_versioning_service.py +698 -0
  27. contentctl_ng/contentctl_legacy_ported/objects/correlation_search.py +1361 -0
  28. contentctl_ng/contentctl_legacy_ported/objects/detection_metadata.py +91 -0
  29. contentctl_ng/contentctl_legacy_ported/objects/enums.py +519 -0
  30. contentctl_ng/contentctl_legacy_ported/objects/errors.py +420 -0
  31. contentctl_ng/contentctl_legacy_ported/objects/integration_test.py +58 -0
  32. contentctl_ng/contentctl_legacy_ported/objects/integration_test_result.py +18 -0
  33. contentctl_ng/contentctl_legacy_ported/objects/manual_test.py +47 -0
  34. contentctl_ng/contentctl_legacy_ported/objects/manual_test_result.py +17 -0
  35. contentctl_ng/contentctl_legacy_ported/objects/mitre_attack_enrichment.py +179 -0
  36. contentctl_ng/contentctl_legacy_ported/objects/notable_action.py +53 -0
  37. contentctl_ng/contentctl_legacy_ported/objects/notable_event.py +27 -0
  38. contentctl_ng/contentctl_legacy_ported/objects/rba.py +190 -0
  39. contentctl_ng/contentctl_legacy_ported/objects/risk_analysis_action.py +114 -0
  40. contentctl_ng/contentctl_legacy_ported/objects/risk_event.py +311 -0
  41. contentctl_ng/contentctl_legacy_ported/objects/risk_object.py +31 -0
  42. contentctl_ng/contentctl_legacy_ported/objects/savedsearch_stanza.py +214 -0
  43. contentctl_ng/contentctl_legacy_ported/objects/savedsearches_conf.py +228 -0
  44. contentctl_ng/contentctl_legacy_ported/objects/test_attack_data.py +72 -0
  45. contentctl_ng/contentctl_legacy_ported/objects/test_group.py +102 -0
  46. contentctl_ng/contentctl_legacy_ported/objects/threat_object.py +28 -0
  47. contentctl_ng/contentctl_legacy_ported/objects/unit_test.py +49 -0
  48. contentctl_ng/contentctl_legacy_ported/objects/unit_test_baseline.py +22 -0
  49. contentctl_ng/contentctl_legacy_ported/objects/unit_test_result.py +97 -0
  50. contentctl_ng/contentctl_legacy_ported/output/yml_writer.py +93 -0
  51. contentctl_ng/demo_main.py +22 -0
  52. contentctl_ng/generate_erdantic_docs.py +24 -0
  53. contentctl_ng/inspect_.py +459 -0
  54. contentctl_ng/install.py +994 -0
  55. contentctl_ng/objects/app.py +356 -0
  56. contentctl_ng/objects/atomic_guid.py +227 -0
  57. contentctl_ng/objects/conf.py +879 -0
  58. contentctl_ng/objects/content.py +951 -0
  59. contentctl_ng/objects/dashboard.py +157 -0
  60. contentctl_ng/objects/data_source.py +423 -0
  61. contentctl_ng/objects/lookup.py +326 -0
  62. contentctl_ng/objects/macro.py +197 -0
  63. contentctl_ng/objects/mitre_enrichment.py +236 -0
  64. contentctl_ng/objects/object_parser.py +416 -0
  65. contentctl_ng/objects/schedule.py +206 -0
  66. contentctl_ng/objects/searches/baseline.py +86 -0
  67. contentctl_ng/objects/searches/detection.py +910 -0
  68. contentctl_ng/objects/searches/search.py +639 -0
  69. contentctl_ng/objects/splstring.py +303 -0
  70. contentctl_ng/objects/story.py +232 -0
  71. contentctl_ng/port.py +65 -0
  72. contentctl_ng/py.typed +0 -0
  73. contentctl_ng/release_notes.py +456 -0
  74. contentctl_ng/resources/datamodels_cim.conf +340 -0
  75. contentctl_ng/resources/detection_count_svg.template +18 -0
  76. contentctl_ng/resources/detection_coverage_svg.template +16 -0
  77. contentctl_ng/resources/install.yml +34 -0
  78. contentctl_ng/resources/port_build_template.yml +50 -0
  79. contentctl_ng/settings.py +596 -0
  80. contentctl_ng/test.py +329 -0
  81. contentctl_ng/utilities/common_io.py +461 -0
  82. contentctl_ng/utilities/port_yml_file.py +997 -0
  83. contentctl_ng/utilities/reporting_tools.py +333 -0
  84. contentctl_ng/version.py +73 -0
  85. contentctl_ng-0.1.0.dist-info/METADATA +26 -0
  86. contentctl_ng-0.1.0.dist-info/RECORD +88 -0
  87. contentctl_ng-0.1.0.dist-info/WHEEL +4 -0
  88. 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"
@@ -0,0 +1,10 @@
1
+ """Allows the main cli entry to be invoked when running contentctl_ng as a module.
2
+
3
+ i.e.:
4
+ python -m contentctl_ng install
5
+ """
6
+
7
+ from contentctl_ng.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
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)