Skip to content

Commit bdda7b6

Browse files
committed
Merge branch 'main' into conf_file_updates
2 parents ac0d920 + 8be90ad commit bdda7b6

File tree

10 files changed

+427
-92
lines changed

10 files changed

+427
-92
lines changed

contentctl/actions/inspect.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
DetectionMissingError,
1717
MetadataValidationError,
1818
VersionBumpingError,
19+
VersionBumpingTooFarError,
1920
VersionDecrementedError,
2021
)
2122
from contentctl.objects.savedsearches_conf import SavedsearchesConf
@@ -101,7 +102,7 @@ def inspectAppAPI(self, config: inspect) -> str:
101102
-F "app_package=@<PATH/APP-PACKAGE>" \
102103
-F "included_tags=cloud" \
103104
--url "https://appinspect.splunk.com/v1/app/validate"
104-
105+
105106
This is confirmed by the great resource:
106107
https://curlconverter.com/
107108
"""
@@ -429,6 +430,19 @@ def check_detection_metadata(self, config: inspect) -> None:
429430
)
430431
)
431432

433+
# Versions should never increase more than one version between releases
434+
if (
435+
current_stanza.metadata.detection_version
436+
> previous_stanza.metadata.detection_version + 1
437+
):
438+
validation_errors[rule_name].append(
439+
VersionBumpingTooFarError(
440+
rule_name=rule_name,
441+
current_version=current_stanza.metadata.detection_version,
442+
previous_version=previous_stanza.metadata.detection_version,
443+
)
444+
)
445+
432446
# Convert our dict mapping to a flat list of errors for use in reporting
433447
validation_error_list = [
434448
x for inner_list in validation_errors.values() for x in inner_list

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: 128 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,101 @@ 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+
# Unfortunately, this is a catch-all for untyped errors. We will still need to emit this
342+
# This is harder to read, but the other option is suppressing it which we cannot do as
343+
# it makes troubleshooting extremelt difficult
344+
print(
345+
f" {Colors.RED}{Colors.ERROR} {error_msg}{Colors.END}"
346+
)
347+
348+
# Clean error categorization
349+
elif "Field required" in error_msg:
350+
print(
351+
f" {Colors.YELLOW}{Colors.WARNING} Field Required: {err.get('loc', [''])[0]}{Colors.END}"
352+
)
353+
elif "Input should be" in error_msg:
354+
print(
355+
f" {Colors.MAGENTA}{Colors.ARROW} Invalid Value for {err.get('loc', [''])[0]}{Colors.END}"
356+
)
357+
if err.get("ctx", {}).get("expected", None) is not None:
358+
print(
359+
f" Valid options: {err.get('ctx', {}).get('expected', None)}"
360+
)
361+
elif "Extra inputs" in error_msg:
362+
print(
363+
f" {Colors.BLUE}{Colors.ERROR} Unexpected Field: {err.get('loc', [''])[0]}{Colors.END}"
364+
)
365+
elif "Failed to find" in error_msg:
366+
print(
367+
f" {Colors.RED}{Colors.SEARCH} Missing Reference: {error_msg}{Colors.END}"
368+
)
369+
else:
370+
print(
371+
f" {Colors.RED}{Colors.ERROR} {error_msg}{Colors.END}"
372+
)
373+
else:
374+
print(f" {Colors.RED}{Colors.ERROR} {str(error)}{Colors.END}")
375+
print("")
376+
377+
# Clean footer with next steps
378+
max_width = max(60, max(len(str(e[0])) + 15 for e in validation_errors))
379+
print(f"{Colors.BOLD}{Colors.CYAN}{'═' * max_width}{Colors.END}")
380+
print(
381+
f"{Colors.BOLD}{Colors.CYAN}{Colors.BLUE}{Colors.ARROW + ' Next Steps':^{max_width - 1}}{Colors.CYAN}{Colors.END}"
382+
)
383+
print(f"{Colors.BOLD}{Colors.CYAN}{'═' * max_width}{Colors.END}\n")
384+
385+
print(
386+
f"{Colors.GREEN}{Colors.TOOLS} Fix the validation issues in the listed files{Colors.END}"
387+
)
388+
print(
389+
f"{Colors.YELLOW}{Colors.DOCS} Check the documentation: {Colors.UNDERLINE}https://github.com/splunk/contentctl{Colors.END}"
390+
)
391+
print(
392+
f"{Colors.BLUE}{Colors.BULB} Use --verbose for detailed error information{Colors.END}\n"
393+
)
394+
395+
raise ValidationFailedError(
396+
f"Validation failed with {len(validation_errors)} error(s)"
397+
)
398+
399+
# Success case
400+
print(
401+
f"\r{f'{contentCartegoryName} Progress'.rjust(23)}: [{progress_percent:3.0f}%]... {Colors.GREEN}{Colors.CHECK_MARK} Done!{Colors.END}"
402+
)

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)