Skip to content

Commit 5b5bbc6

Browse files
authored
Merge branch 'main' into dependabot/pip/setuptools-gte-69.5.1-and-lt-81.0.0
2 parents eb427a2 + df437ae commit 5b5bbc6

File tree

8 files changed

+378
-88
lines changed

8 files changed

+378
-88
lines changed

contentctl/actions/validate.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from contentctl.enrichments.cve_enrichment import CveEnrichment
55
from contentctl.helper.splunk_app import SplunkApp
66
from contentctl.helper.utils import Utils
7-
from contentctl.input.director import Director, DirectorOutputDto
7+
from contentctl.input.director import Director, DirectorOutputDto, ValidationFailedError
88
from contentctl.objects.atomic import AtomicEnrichment
99
from contentctl.objects.config import validate
1010
from contentctl.objects.data_source import DataSource
@@ -13,19 +13,26 @@
1313

1414
class Validate:
1515
def execute(self, input_dto: validate) -> DirectorOutputDto:
16-
director_output_dto = DirectorOutputDto(
17-
AtomicEnrichment.getAtomicEnrichment(input_dto),
18-
AttackEnrichment.getAttackEnrichment(input_dto),
19-
CveEnrichment.getCveEnrichment(input_dto),
20-
)
16+
try:
17+
director_output_dto = DirectorOutputDto(
18+
AtomicEnrichment.getAtomicEnrichment(input_dto),
19+
AttackEnrichment.getAttackEnrichment(input_dto),
20+
CveEnrichment.getCveEnrichment(input_dto),
21+
)
22+
23+
director = Director(director_output_dto)
24+
director.execute(input_dto)
25+
self.ensure_no_orphaned_files_in_lookups(
26+
input_dto.path, director_output_dto
27+
)
28+
if input_dto.data_source_TA_validation:
29+
self.validate_latest_TA_information(director_output_dto.data_sources)
2130

22-
director = Director(director_output_dto)
23-
director.execute(input_dto)
24-
self.ensure_no_orphaned_files_in_lookups(input_dto.path, director_output_dto)
25-
if input_dto.data_source_TA_validation:
26-
self.validate_latest_TA_information(director_output_dto.data_sources)
31+
return director_output_dto
2732

28-
return director_output_dto
33+
except ValidationFailedError:
34+
# Just re-raise without additional output since we already formatted everything
35+
raise SystemExit(1)
2936

3037
def ensure_no_orphaned_files_in_lookups(
3138
self, repo_path: pathlib.Path, director_output_dto: DirectorOutputDto

contentctl/input/director.py

Lines changed: 123 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,40 @@ def addContentToDictMappings(self, content: SecurityContentObject):
109109
self.uuid_to_content_map[content.id] = content
110110

111111

112+
class Colors:
113+
HEADER = "\033[95m"
114+
BLUE = "\033[94m"
115+
CYAN = "\033[96m"
116+
GREEN = "\033[92m"
117+
YELLOW = "\033[93m"
118+
RED = "\033[91m"
119+
BOLD = "\033[1m"
120+
UNDERLINE = "\033[4m"
121+
END = "\033[0m"
122+
MAGENTA = "\033[35m"
123+
BRIGHT_MAGENTA = "\033[95m"
124+
125+
# Add fallback symbols for Windows
126+
CHECK_MARK = "✓" if sys.platform != "win32" else "*"
127+
WARNING = "⚠️" if sys.platform != "win32" else "!"
128+
ERROR = "❌" if sys.platform != "win32" else "X"
129+
ARROW = "🎯" if sys.platform != "win32" else ">"
130+
TOOLS = "🛠️" if sys.platform != "win32" else "#"
131+
DOCS = "📚" if sys.platform != "win32" else "?"
132+
BULB = "💡" if sys.platform != "win32" else "i"
133+
SEARCH = "🔍" if sys.platform != "win32" else "@"
134+
SPARKLE = "✨" if sys.platform != "win32" else "*"
135+
ZAP = "⚡" if sys.platform != "win32" else "!"
136+
137+
138+
class ValidationFailedError(Exception):
139+
"""Custom exception for validation failures that already have formatted output."""
140+
141+
def __init__(self, message: str):
142+
self.message = message
143+
super().__init__(message)
144+
145+
112146
class Director:
113147
input_dto: validate
114148
output_dto: DirectorOutputDto
@@ -268,18 +302,96 @@ def createSecurityContent(
268302
end="",
269303
flush=True,
270304
)
271-
print("Done!")
272305

273306
if len(validation_errors) > 0:
274-
errors_string = "\n\n".join(
275-
[
276-
f"File: {e_tuple[0]}\nError: {str(e_tuple[1])}"
277-
for e_tuple in validation_errors
278-
]
307+
if sys.platform == "win32":
308+
sys.stdout.reconfigure(encoding="utf-8")
309+
310+
print("\n") # Clean separation
311+
print(f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}{'═' * 60}{Colors.END}")
312+
print(
313+
f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}{Colors.BLUE}{f'{Colors.SEARCH} Content Validation Summary':^59}{Colors.BRIGHT_MAGENTA}{Colors.END}"
279314
)
280-
# print(f"The following {len(validation_errors)} error(s) were found during validation:\n\n{errors_string}\n\nVALIDATION FAILED")
281-
# We quit after validation a single type/group of content because it can cause significant cascading errors in subsequent
282-
# types of content (since they may import or otherwise use it)
283-
raise Exception(
284-
f"The following {len(validation_errors)} error(s) were found during validation:\n\n{errors_string}\n\nVALIDATION FAILED"
315+
print(f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}{'═' * 60}{Colors.END}\n")
316+
317+
print(
318+
f"{Colors.BOLD}{Colors.GREEN}{Colors.SPARKLE} Validation Completed{Colors.END} – Issues detected in {Colors.RED}{Colors.BOLD}{len(validation_errors)}{Colors.END} files.\n"
285319
)
320+
321+
for index, entry in enumerate(validation_errors, 1):
322+
file_path, error = entry
323+
width = max(70, len(str(file_path)) + 15)
324+
325+
# File header with numbered emoji
326+
number_emoji = f"{index}️⃣"
327+
print(f"{Colors.YELLOW}{'━' * width}{Colors.END}")
328+
print(
329+
f"{Colors.YELLOW}{Colors.BOLD} {number_emoji} File: {Colors.CYAN}{file_path}{Colors.END}{' ' * (width - len(str(file_path)) - 9)}{Colors.YELLOW}{Colors.END}"
330+
)
331+
print(f"{Colors.YELLOW}{'━' * width}{Colors.END}")
332+
333+
print(
334+
f" {Colors.RED}{Colors.BOLD}{Colors.ZAP} Validation Issues:{Colors.END}"
335+
)
336+
337+
if isinstance(error, ValidationError):
338+
for err in error.errors():
339+
error_msg = err.get("msg", "")
340+
if "https://errors.pydantic.dev" in error_msg:
341+
continue
342+
343+
# Clean error categorization
344+
if "Field required" in error_msg:
345+
print(
346+
f" {Colors.YELLOW}{Colors.WARNING} Field Required: {err.get('loc', [''])[0]}{Colors.END}"
347+
)
348+
elif "Input should be" in error_msg:
349+
print(
350+
f" {Colors.MAGENTA}{Colors.ARROW} Invalid Value for {err.get('loc', [''])[0]}{Colors.END}"
351+
)
352+
if err.get("ctx", {}).get("expected", None) is not None:
353+
print(
354+
f" Valid options: {err.get('ctx', {}).get('expected', None)}"
355+
)
356+
elif "Extra inputs" in error_msg:
357+
print(
358+
f" {Colors.BLUE}{Colors.ERROR} Unexpected Field: {err.get('loc', [''])[0]}{Colors.END}"
359+
)
360+
elif "Failed to find" in error_msg:
361+
print(
362+
f" {Colors.RED}{Colors.SEARCH} Missing Reference: {error_msg}{Colors.END}"
363+
)
364+
else:
365+
print(
366+
f" {Colors.RED}{Colors.ERROR} {error_msg}{Colors.END}"
367+
)
368+
else:
369+
print(f" {Colors.RED}{Colors.ERROR} {str(error)}{Colors.END}")
370+
print("")
371+
372+
# Clean footer with next steps
373+
max_width = max(60, max(len(str(e[0])) + 15 for e in validation_errors))
374+
print(f"{Colors.BOLD}{Colors.CYAN}{'═' * max_width}{Colors.END}")
375+
print(
376+
f"{Colors.BOLD}{Colors.CYAN}{Colors.BLUE}{Colors.ARROW + ' Next Steps':^{max_width - 1}}{Colors.CYAN}{Colors.END}"
377+
)
378+
print(f"{Colors.BOLD}{Colors.CYAN}{'═' * max_width}{Colors.END}\n")
379+
380+
print(
381+
f"{Colors.GREEN}{Colors.TOOLS} Fix the validation issues in the listed files{Colors.END}"
382+
)
383+
print(
384+
f"{Colors.YELLOW}{Colors.DOCS} Check the documentation: {Colors.UNDERLINE}https://github.com/splunk/contentctl{Colors.END}"
385+
)
386+
print(
387+
f"{Colors.BLUE}{Colors.BULB} Use --verbose for detailed error information{Colors.END}\n"
388+
)
389+
390+
raise ValidationFailedError(
391+
f"Validation failed with {len(validation_errors)} error(s)"
392+
)
393+
394+
# Success case
395+
print(
396+
f"\r{f'{contentCartegoryName} Progress'.rjust(23)}: [{progress_percent:3.0f}%]... {Colors.GREEN}{Colors.CHECK_MARK} Done!{Colors.END}"
397+
)

contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import pprint
1313
import uuid
1414
from abc import abstractmethod
15+
from difflib import get_close_matches
1516
from functools import cached_property
1617
from typing import List, Optional, Tuple, Union
1718

@@ -700,16 +701,18 @@ def getReferencesListForJson(self) -> List[str]:
700701
def mapNamesToSecurityContentObjects(
701702
cls, v: list[str], director: Union[DirectorOutputDto, None]
702703
) -> list[Self]:
703-
if director is not None:
704-
name_map = director.name_to_content_map
705-
else:
706-
name_map = {}
704+
if director is None:
705+
raise Exception(
706+
"Direction was 'None' when passed to "
707+
"'mapNamesToSecurityContentObjects'. This is "
708+
"an error in the contentctl codebase which must be resolved."
709+
)
707710

708711
mappedObjects: list[Self] = []
709712
mistyped_objects: list[SecurityContentObject_Abstract] = []
710713
missing_objects: list[str] = []
711714
for object_name in v:
712-
found_object = name_map.get(object_name, None)
715+
found_object = director.name_to_content_map.get(object_name, None)
713716
if not found_object:
714717
missing_objects.append(object_name)
715718
elif not isinstance(found_object, cls):
@@ -718,22 +721,40 @@ def mapNamesToSecurityContentObjects(
718721
mappedObjects.append(found_object)
719722

720723
errors: list[str] = []
721-
if len(missing_objects) > 0:
724+
for missing_object in missing_objects:
725+
if missing_object.endswith("_filter"):
726+
# Most filter macros are defined as empty at runtime, so we do not
727+
# want to make any suggestions. It is time consuming and not helpful
728+
# to make these suggestions, so we just skip them in this check.
729+
continue
730+
matches = get_close_matches(
731+
missing_object,
732+
director.name_to_content_map.keys(),
733+
n=3,
734+
)
735+
if matches == []:
736+
matches = ["NO SUGGESTIONS"]
737+
738+
matches_string = ", ".join(matches)
722739
errors.append(
723-
f"Failed to find the following '{cls.__name__}': {missing_objects}"
740+
f"Unable to find: {missing_object}\n Suggestions: {matches_string}"
741+
)
742+
743+
for mistyped_object in mistyped_objects:
744+
matches = get_close_matches(
745+
mistyped_object.name, director.name_to_content_map.keys(), n=3
746+
)
747+
748+
errors.append(
749+
f"'{mistyped_object.name}' expected to have type '{cls.__name__}', but actually "
750+
f"had type '{type(mistyped_object).__name__}'"
724751
)
725-
if len(mistyped_objects) > 0:
726-
for mistyped_object in mistyped_objects:
727-
errors.append(
728-
f"'{mistyped_object.name}' expected to have type '{cls}', but actually "
729-
f"had type '{type(mistyped_object)}'"
730-
)
731752

732753
if len(errors) > 0:
733-
error_string = "\n - ".join(errors)
754+
error_string = "\n\n - ".join(errors)
734755
raise ValueError(
735-
f"Found {len(errors)} issues when resolving references Security Content Object "
736-
f"names:\n - {error_string}"
756+
f"Found {len(errors)} issues when resolving references to '{cls.__name__}' objects:\n"
757+
f" - {error_string}"
737758
)
738759

739760
# Sort all objects sorted by name
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from abc import ABC, abstractmethod
2+
3+
from pydantic import BaseModel, ConfigDict
4+
5+
from contentctl.objects.detection import Detection
6+
7+
8+
class BaseSecurityEvent(BaseModel, ABC):
9+
"""
10+
Base event class for a Splunk security event (e.g. risks and notables)
11+
"""
12+
13+
# The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule")
14+
search_name: str
15+
16+
# The search ID that found that generated this event
17+
orig_sid: str
18+
19+
# Allowing fields that aren't explicitly defined to be passed since some of the risk/notable
20+
# event's fields vary depending on the SPL which generated them
21+
model_config = ConfigDict(extra="allow")
22+
23+
@abstractmethod
24+
def validate_against_detection(self, detection: Detection) -> None:
25+
"""
26+
Validate this risk/notable event against the given detection
27+
"""
28+
raise NotImplementedError()

0 commit comments

Comments
 (0)