Skip to content

Commit 1974503

Browse files
committed
More cleanup and error handling
to how enrichment is done. It now works as expected and the proper errors are thrown for AtomicEnrichment depending on the --enrichments cli setting.
1 parent b76dd76 commit 1974503

File tree

5 files changed

+64
-94
lines changed

5 files changed

+64
-94
lines changed

contentctl/actions/validate.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,16 @@
55
from contentctl.objects.config import validate
66
from contentctl.enrichments.attack_enrichment import AttackEnrichment
77
from contentctl.enrichments.cve_enrichment import CveEnrichment
8-
from contentctl.objects.atomic import AtomicTest
8+
from contentctl.objects.atomic import AtomicEnrichment
99
from contentctl.helper.utils import Utils
1010
from contentctl.objects.data_source import DataSource
1111
from contentctl.helper.splunk_app import SplunkApp
1212

1313

1414
class Validate:
15-
def execute(self, input_dto: validate) -> DirectorOutputDto:
16-
15+
def execute(self, input_dto: validate) -> DirectorOutputDto:
1716
director_output_dto = DirectorOutputDto(
18-
AtomicTest.getAtomicTestsFromArtRepo(
19-
repo_path=input_dto.atomic_red_team_repo_path,
20-
enabled=input_dto.enrichments,
21-
),
17+
AtomicEnrichment.getAtomicEnrichment(input_dto),
2218
AttackEnrichment.getAttackEnrichment(input_dto),
2319
CveEnrichment.getCveEnrichment(input_dto),
2420
[],

contentctl/enrichments/attack_enrichment.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def getAttackEnrichment(config:validate)->AttackEnrichment:
2525

2626
def getEnrichmentByMitreID(self, mitre_id:MITRE_ATTACK_ID_TYPE)->MitreAttackEnrichment:
2727
if not self.use_enrichment:
28-
raise Exception(f"Error, trying to add Mitre Enrichment, but use_enrichment was set to False")
28+
raise Exception("Error, trying to add Mitre Enrichment, but use_enrichment was set to False")
2929

3030
enrichment = self.data.get(mitre_id, None)
3131
if enrichment is not None:

contentctl/input/director.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from contentctl.objects.deployment import Deployment
1919
from contentctl.objects.macro import Macro
2020
from contentctl.objects.lookup import Lookup
21-
from contentctl.objects.atomic import AtomicTest
21+
from contentctl.objects.atomic import AtomicEnrichment
2222
from contentctl.objects.security_content_object import SecurityContentObject
2323
from contentctl.objects.data_source import DataSource
2424
from contentctl.objects.event_source import EventSource
@@ -39,7 +39,7 @@
3939
class DirectorOutputDto:
4040
# Atomic Tests are first because parsing them
4141
# is far quicker than attack_enrichment
42-
atomic_tests: None | list[AtomicTest]
42+
atomic_enrichment: AtomicEnrichment
4343
attack_enrichment: AttackEnrichment
4444
cve_enrichment: CveEnrichment
4545
detections: list[Detection]
@@ -145,7 +145,7 @@ def createSecurityContent(self, contentType: SecurityContentType) -> None:
145145
f for f in files
146146
]
147147
else:
148-
raise (Exception(f"Cannot createSecurityContent for unknown product."))
148+
raise (Exception(f"Cannot createSecurityContent for unknown product {contentType}."))
149149

150150
validation_errors = []
151151

contentctl/objects/atomic.py

Lines changed: 51 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from __future__ import annotations
2+
from typing import TYPE_CHECKING
3+
if TYPE_CHECKING:
4+
from contentctl.objects.config import validate
5+
26
from contentctl.input.yml_reader import YmlReader
37
from pydantic import BaseModel, model_validator, ConfigDict, FilePath, UUID4
8+
import dataclasses
49
from typing import List, Optional, Dict, Union, Self
510
import pathlib
6-
7-
811
from enum import StrEnum, auto
9-
12+
import uuid
1013

1114
class SupportedPlatform(StrEnum):
1215
windows = auto()
@@ -84,47 +87,23 @@ class AtomicTest(BaseModel):
8487
dependencies: Optional[List[AtomicDependency]] = None
8588
dependency_executor_name: Optional[DependencyExecutorType] = None
8689

87-
@staticmethod
88-
def AtomicTestWhenEnrichmentIsDisabled(auto_generated_guid: UUID4) -> AtomicTest:
89-
return AtomicTest(name="Placeholder Atomic Test (enrichment disabled)",
90-
auto_generated_guid=auto_generated_guid,
91-
description="This is a placeholder AtomicTest. Because enrichments were not enabled, it has not been validated against the real Atomic Red Team Repo.",
92-
supported_platforms=[],
93-
executor=AtomicExecutor(name="Placeholder Executor (enrichment disabled)",
94-
command="Placeholder command (enrichment disabled)"))
95-
9690
@staticmethod
9791
def AtomicTestWhenTestIsMissing(auto_generated_guid: UUID4) -> AtomicTest:
9892
return AtomicTest(name="Missing Atomic",
9993
auto_generated_guid=auto_generated_guid,
10094
description="This is a placeholder AtomicTest. Either the auto_generated_guid is incorrect or it there was an exception while parsing its AtomicFile.",
10195
supported_platforms=[],
10296
executor=AtomicExecutor(name="Placeholder Executor (failed to find auto_generated_guid)",
103-
command="Placeholder command (failed to find auto_generated_guid)"))
104-
105-
106-
@classmethod
107-
def getAtomicByAtomicGuid(cls, guid: UUID4, all_atomics:list[AtomicTest] | None)->AtomicTest:
108-
if all_atomics is None:
109-
return AtomicTest.AtomicTestWhenEnrichmentIsDisabled(guid)
110-
matching_atomics = [atomic for atomic in all_atomics if atomic.auto_generated_guid == guid]
111-
if len(matching_atomics) == 0:
112-
raise ValueError(f"Unable to find atomic_guid {guid} in {len(all_atomics)} atomic_tests from ART Repo")
113-
elif len(matching_atomics) > 1:
114-
raise ValueError(f"Found {len(matching_atomics)} matching tests for atomic_guid {guid} in {len(all_atomics)} atomic_tests from ART Repo")
115-
116-
return matching_atomics[0]
97+
command="Placeholder command (failed to find auto_generated_guid)"))
11798

11899
@classmethod
119-
def parseArtRepo(cls, repo_path:pathlib.Path)->List[AtomicFile]:
120-
if not repo_path.is_dir():
121-
print(f"WARNING: Atomic Red Team repo does NOT exist at {repo_path.absolute()}. You can check it out with:\n * git clone --single-branch https://github.com/redcanaryco/atomic-red-team. This will ONLY throw a validation error if you reference atomid_guids in your detection(s).")
122-
return []
100+
def parseArtRepo(cls, repo_path:pathlib.Path)->dict[uuid.UUID, AtomicTest]:
101+
test_mapping: dict[uuid.UUID, AtomicTest] = {}
123102
atomics_path = repo_path/"atomics"
124103
if not atomics_path.is_dir():
125-
print(f"WARNING: Atomic Red Team repo exists at {repo_path.absolute}, but atomics directory does NOT exist at {atomics_path.absolute()}. Was it deleted or renamed? This will ONLY throw a validation error if you reference atomid_guids in your detection(s).")
126-
return []
127-
104+
raise FileNotFoundError(f"WARNING: Atomic Red Team repo exists at {repo_path}, "
105+
f"but atomics directory does NOT exist at {atomics_path}. "
106+
"Was it deleted or renamed?")
128107

129108
atomic_files:List[AtomicFile] = []
130109
error_messages:List[str] = []
@@ -133,45 +112,36 @@ def parseArtRepo(cls, repo_path:pathlib.Path)->List[AtomicFile]:
133112
atomic_files.append(cls.constructAtomicFile(obj_path))
134113
except Exception as e:
135114
error_messages.append(f"File [{obj_path}]\n{str(e)}")
115+
136116
if len(error_messages) > 0:
137117
exceptions_string = '\n\n'.join(error_messages)
138118
print(f"WARNING: The following [{len(error_messages)}] ERRORS were generated when parsing the Atomic Red Team Repo.\n"
139119
"Please raise an issue so that they can be fixed at https://github.com/redcanaryco/atomic-red-team/issues.\n"
140120
"Note that this is only a warning and contentctl will ignore Atomics contained in these files.\n"
141121
f"However, if you have written a detection that references them, 'contentctl build --enrichments' will fail:\n\n{exceptions_string}")
142122

143-
return atomic_files
123+
# Now iterate over all the files, collect all the tests, and return the dict mapping
124+
redefined_guids:set[uuid.UUID] = set()
125+
for atomic_file in atomic_files:
126+
for atomic_test in atomic_file.atomic_tests:
127+
if atomic_test.auto_generated_guid in test_mapping:
128+
redefined_guids.add(atomic_test.auto_generated_guid)
129+
else:
130+
test_mapping[atomic_test.auto_generated_guid] = atomic_test
131+
if len(redefined_guids) > 0:
132+
guids_string = '\n\t'.join([str(guid) for guid in redefined_guids])
133+
raise Exception(f"The following [{len(redefined_guids)}] Atomic Test"
134+
" auto_generated_guid(s) were defined more than once. "
135+
f"auto_generated_guids MUST be unique:\n\t{guids_string}")
136+
137+
print(f"Successfully parsed [{len(test_mapping)}] Atomic Red Team Tests!")
138+
return test_mapping
144139

145140
@classmethod
146141
def constructAtomicFile(cls, file_path:pathlib.Path)->AtomicFile:
147142
yml_dict = YmlReader.load_file(file_path)
148143
atomic_file = AtomicFile.model_validate(yml_dict)
149144
return atomic_file
150-
151-
@classmethod
152-
def getAtomicTestsFromArtRepo(cls, repo_path:pathlib.Path, enabled:bool=True)->list[AtomicTest] | None:
153-
# Get all the atomic files. Note that if the ART repo is not found, we will not throw an error,
154-
# but will not have any atomics. This means that if atomic_guids are referenced during validation,
155-
# validation for those detections will fail
156-
if not enabled:
157-
return None
158-
159-
atomic_files = cls.getAtomicFilesFromArtRepo(repo_path)
160-
161-
atomic_tests:List[AtomicTest] = []
162-
for atomic_file in atomic_files:
163-
atomic_tests.extend(atomic_file.atomic_tests)
164-
print(f"Found [{len(atomic_tests)}] Atomic Simulations in the Atomic Red Team Repo!")
165-
return atomic_tests
166-
167-
168-
@classmethod
169-
def getAtomicFilesFromArtRepo(cls, repo_path:pathlib.Path)->List[AtomicFile]:
170-
return cls.parseArtRepo(repo_path)
171-
172-
173-
174-
175145

176146

177147
class AtomicFile(BaseModel):
@@ -182,27 +152,31 @@ class AtomicFile(BaseModel):
182152
atomic_tests: List[AtomicTest]
183153

184154

155+
class AtomicEnrichment(BaseModel):
156+
data: dict[uuid.UUID,AtomicTest] = dataclasses.field(default_factory = dict)
157+
use_enrichment: bool = False
185158

159+
@classmethod
160+
def getAtomicEnrichment(cls, config:validate)->AtomicEnrichment:
161+
enrichment = AtomicEnrichment(use_enrichment=config.enrichments)
162+
if config.enrichments:
163+
enrichment.data = AtomicTest.parseArtRepo(config.atomic_red_team_repo_path)
164+
165+
return enrichment
166+
167+
def getAtomic(self, atomic_guid: uuid.UUID)->AtomicTest:
168+
if self.use_enrichment:
169+
if atomic_guid in self.data:
170+
return self.data[atomic_guid]
171+
else:
172+
raise Exception(f"Atomic with GUID {atomic_guid} not found.")
173+
else:
174+
# If enrichment is not enabled, for the sake of compatability
175+
# return a stub test with no useful or meaningful information.
176+
return AtomicTest.AtomicTestWhenTestIsMissing(atomic_guid)
186177

187-
# ATOMICS_PATH = pathlib.Path("./atomics")
188-
# atomic_objects = []
189-
# atomic_simulations = []
190-
# for obj_path in ATOMICS_PATH.glob("**/T*.yaml"):
191-
# try:
192-
# with open(obj_path, 'r', encoding="utf-8") as obj_handle:
193-
# obj_data = yaml.load(obj_handle, Loader=yaml.CSafeLoader)
194-
# atomic_obj = AtomicFile.model_validate(obj_data)
195-
# except Exception as e:
196-
# print(f"Error parsing object at path {obj_path}: {str(e)}")
197-
# print(f"We have successfully parsed {len(atomic_objects)}, however!")
198-
# sys.exit(1)
199-
200-
# print(f"Successfully parsed {obj_path}!")
201-
# atomic_objects.append(atomic_obj)
202-
# atomic_simulations += atomic_obj.atomic_tests
178+
203179

204-
# print(f"Successfully parsed all {len(atomic_objects)} files!")
205-
# print(f"Successfully parsed all {len(atomic_simulations)} simulations!")
206180

207181

208182

contentctl/objects/detection_tags.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22
import uuid
3-
from typing import TYPE_CHECKING, List, Optional, Annotated, Union
3+
from typing import TYPE_CHECKING, List, Optional, Union
44
from pydantic import (
55
BaseModel,
66
Field,
@@ -32,7 +32,7 @@
3232
RiskLevel,
3333
SecurityContentProductName
3434
)
35-
from contentctl.objects.atomic import AtomicTest
35+
from contentctl.objects.atomic import AtomicEnrichment, AtomicTest
3636
from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE, CVE_TYPE
3737

3838
# TODO (#266): disable the use_enum_values configuration
@@ -240,7 +240,7 @@ def mapAtomicGuidsToAtomicTests(cls, v: List[UUID4], info: ValidationInfo) -> Li
240240
if output_dto is None:
241241
raise ValueError("Context not provided to detection.detection_tags.atomic_guid validator")
242242

243-
all_tests: None | List[AtomicTest] = output_dto.atomic_tests
243+
atomic_enrichment: AtomicEnrichment = output_dto.atomic_enrichment
244244

245245
matched_tests: List[AtomicTest] = []
246246
missing_tests: List[UUID4] = []
@@ -254,7 +254,7 @@ def mapAtomicGuidsToAtomicTests(cls, v: List[UUID4], info: ValidationInfo) -> Li
254254
badly_formatted_guids.append(str(atomic_guid_str))
255255
continue
256256
try:
257-
matched_tests.append(AtomicTest.getAtomicByAtomicGuid(atomic_guid, all_tests))
257+
matched_tests.append(atomic_enrichment.getAtomic(atomic_guid))
258258
except Exception:
259259
missing_tests.append(atomic_guid)
260260

@@ -265,7 +265,7 @@ def mapAtomicGuidsToAtomicTests(cls, v: List[UUID4], info: ValidationInfo) -> Li
265265
f"\n\tPlease review the output above for potential exception(s) when parsing the "
266266
"Atomic Red Team Repo."
267267
"\n\tVerify that these auto_generated_guid exist and try updating/pulling the "
268-
f"repo again.: {[str(guid) for guid in missing_tests]}"
268+
f"repo again: {[str(guid) for guid in missing_tests]}"
269269
)
270270
else:
271271
missing_tests_string = ""
@@ -278,6 +278,6 @@ def mapAtomicGuidsToAtomicTests(cls, v: List[UUID4], info: ValidationInfo) -> Li
278278
raise ValueError(f"{bad_guids_string}{missing_tests_string}")
279279

280280
elif len(missing_tests) > 0:
281-
print(missing_tests_string)
281+
raise ValueError(missing_tests_string)
282282

283283
return matched_tests + [AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests]

0 commit comments

Comments
 (0)