Skip to content

Commit 15af126

Browse files
authored
Merge branch 'main' into feature/validation_against_cms_main
2 parents fd9b0cc + 88dd586 commit 15af126

File tree

9 files changed

+118
-53
lines changed

9 files changed

+118
-53
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ repos:
88
- id: detect-private-key
99
- id: forbid-submodules
1010
- repo: https://github.com/astral-sh/ruff-pre-commit
11-
rev: v0.9.2
11+
rev: v0.11.0
1212
hooks:
1313
- id: ruff
1414
args: [ --fix ]

contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ class DetectionTestingManagerOutputDto:
100100
start_time: Union[datetime.datetime, None] = None
101101
replay_index: str = "contentctl_testing_index"
102102
replay_host: str = "CONTENTCTL_HOST"
103-
timeout_seconds: int = 60
103+
timeout_seconds: int = 120
104104
terminate: bool = False
105105

106106

contentctl/objects/abstract_security_content_objects/detection_abstract.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ def serialize_model(self):
474474
"name": lookup.name,
475475
"description": lookup.description,
476476
"filename": lookup.filename.name,
477-
"default_match": "true" if lookup.default_match else "false",
477+
"default_match": lookup.default_match,
478478
"case_sensitive_match": "true"
479479
if lookup.case_sensitive_match
480480
else "false",
@@ -1055,3 +1055,30 @@ def get_summary(
10551055
# Return the summary
10561056

10571057
return summary_dict
1058+
1059+
@model_validator(mode="after")
1060+
def validate_data_source_output_fields(self):
1061+
# Skip validation for Hunting and Correlation types, or non-production detections
1062+
if self.status != DetectionStatus.production or self.type in {
1063+
AnalyticsType.Hunting,
1064+
AnalyticsType.Correlation,
1065+
}:
1066+
return self
1067+
1068+
# Validate that all required output fields are present in the search
1069+
for data_source in self.data_source_objects:
1070+
if not data_source.output_fields:
1071+
continue
1072+
1073+
missing_fields = [
1074+
field for field in data_source.output_fields if field not in self.search
1075+
]
1076+
1077+
if missing_fields:
1078+
raise ValueError(
1079+
f"Data source '{data_source.name}' has output fields "
1080+
f"{missing_fields} that are not present in the search "
1081+
f"for detection '{self.name}'"
1082+
)
1083+
1084+
return self

contentctl/objects/data_source.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ class DataSource(SecurityContentObject):
1717
source: str = Field(...)
1818
sourcetype: str = Field(...)
1919
separator: Optional[str] = None
20+
separator_value: None | str = None
2021
configuration: Optional[str] = None
2122
supported_TA: list[TA] = []
2223
fields: None | list = None
2324
field_mappings: None | list = None
25+
mitre_components: list[str] = []
2426
convert_to_log_source: None | list = None
2527
example_log: None | str = None
2628
output_fields: list[str] = []

contentctl/objects/lookup.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
import re
77
from enum import StrEnum, auto
88
from functools import cached_property
9-
from typing import TYPE_CHECKING, Annotated, Any, Literal, Optional, Self
9+
from typing import TYPE_CHECKING, Annotated, Any, Literal, Self
1010

1111
from pydantic import (
12+
BeforeValidator,
1213
Field,
1314
FilePath,
1415
NonNegativeInt,
@@ -69,7 +70,19 @@ class Lookup_Type(StrEnum):
6970

7071
# TODO (#220): Split Lookup into 2 classes
7172
class Lookup(SecurityContentObject, abc.ABC):
72-
default_match: Optional[bool] = None
73+
# We need to make sure that this is converted to a string because we widely
74+
# use the string "False" in our lookup content. However, PyYAML reads this
75+
# as a BOOL and this causes parsing to fail. As such, we will always
76+
# convert this to a string if it is passed as a bool
77+
default_match: Annotated[
78+
str, BeforeValidator(lambda dm: str(dm).lower() if isinstance(dm, bool) else dm)
79+
] = Field(
80+
default="",
81+
description="This field is given a default value of ''"
82+
"because it is the default value specified in the transforms.conf "
83+
"docs. Giving it a type of str rather than str | None simplifies "
84+
"the typing for the field.",
85+
)
7386
# Per the documentation for transforms.conf, EXACT should not be specified in this list,
7487
# so we include only WILDCARD and CIDR
7588
match_type: list[Annotated[str, Field(pattern=r"(^WILDCARD|CIDR)\(.+\)$")]] = Field(
@@ -88,7 +101,7 @@ def serialize_model(self):
88101

89102
# All fields custom to this model
90103
model = {
91-
"default_match": "true" if self.default_match is True else "false",
104+
"default_match": self.default_match,
92105
"match_type": self.match_type_to_conf_format,
93106
"min_matches": self.min_matches,
94107
"max_matches": self.max_matches,

contentctl/output/attack_nav_output.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from typing import List, Union
21
import pathlib
2+
from typing import List, Union
33

44
from contentctl.objects.detection import Detection
55
from contentctl.output.attack_nav_writer import AttackNavWriter
@@ -10,14 +10,21 @@ def writeObjects(
1010
self, detections: List[Detection], output_path: pathlib.Path
1111
) -> None:
1212
techniques: dict[str, dict[str, Union[List[str], int]]] = {}
13+
1314
for detection in detections:
1415
for tactic in detection.tags.mitre_attack_id:
1516
if tactic not in techniques:
1617
techniques[tactic] = {"score": 0, "file_paths": []}
1718

18-
detection_url = f"https://github.com/splunk/security_content/blob/develop/detections/{detection.source}/{detection.file_path.name}"
19-
techniques[tactic]["score"] += 1
20-
techniques[tactic]["file_paths"].append(detection_url)
19+
detection_type = detection.source
20+
detection_id = detection.id
21+
22+
# Store all three pieces of information separately
23+
detection_info = f"{detection_type}|{detection_id}|{detection.name}"
24+
25+
techniques[tactic]["score"] = techniques[tactic].get("score", 0) + 1
26+
if isinstance(techniques[tactic]["file_paths"], list):
27+
techniques[tactic]["file_paths"].append(detection_info)
2128

2229
"""
2330
for detection in objects:

contentctl/output/attack_nav_writer.py

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import json
2-
from typing import Union, List
32
import pathlib
3+
from typing import List, Union
44

5-
VERSION = "4.3"
5+
VERSION = "4.5"
66
NAME = "Detection Coverage"
7-
DESCRIPTION = "security_content detection coverage"
8-
DOMAIN = "mitre-enterprise"
7+
DESCRIPTION = "Security Content Detection Coverage"
8+
DOMAIN = "enterprise-attack"
99

1010

1111
class AttackNavWriter:
@@ -14,52 +14,68 @@ def writeAttackNavFile(
1414
mitre_techniques: dict[str, dict[str, Union[List[str], int]]],
1515
output_path: pathlib.Path,
1616
) -> None:
17-
max_count = 0
18-
for technique_id in mitre_techniques.keys():
19-
if mitre_techniques[technique_id]["score"] > max_count:
20-
max_count = mitre_techniques[technique_id]["score"]
17+
max_count = max(
18+
(technique["score"] for technique in mitre_techniques.values()), default=0
19+
)
2120

2221
layer_json = {
23-
"version": VERSION,
22+
"versions": {"attack": "16", "navigator": "5.1.0", "layer": VERSION},
2423
"name": NAME,
2524
"description": DESCRIPTION,
2625
"domain": DOMAIN,
2726
"techniques": [],
27+
"gradient": {
28+
"colors": ["#ffffff", "#66b1ff", "#096ed7"],
29+
"minValue": 0,
30+
"maxValue": max_count,
31+
},
32+
"filters": {
33+
"platforms": [
34+
"Windows",
35+
"Linux",
36+
"macOS",
37+
"Network",
38+
"AWS",
39+
"GCP",
40+
"Azure",
41+
"Azure AD",
42+
"Office 365",
43+
"SaaS",
44+
]
45+
},
46+
"layout": {
47+
"layout": "side",
48+
"showName": True,
49+
"showID": True,
50+
"showAggregateScores": False,
51+
},
52+
"legendItems": [
53+
{"label": "No detections", "color": "#ffffff"},
54+
{"label": "Has detections", "color": "#66b1ff"},
55+
],
56+
"showTacticRowBackground": True,
57+
"tacticRowBackground": "#dddddd",
58+
"selectTechniquesAcrossTactics": True,
2859
}
2960

30-
layer_json["gradient"] = {
31-
"colors": ["#ffffff", "#66b1ff", "#096ed7"],
32-
"minValue": 0,
33-
"maxValue": max_count,
34-
}
35-
36-
layer_json["filters"] = {
37-
"platforms": [
38-
"Windows",
39-
"Linux",
40-
"macOS",
41-
"AWS",
42-
"GCP",
43-
"Azure",
44-
"Office 365",
45-
"SaaS",
46-
]
47-
}
61+
for technique_id, data in mitre_techniques.items():
62+
links = []
63+
for detection_info in data["file_paths"]:
64+
# Split the detection info into its components
65+
detection_type, detection_id, detection_name = detection_info.split("|")
4866

49-
layer_json["legendItems"] = [
50-
{"label": "NO available detections", "color": "#ffffff"},
51-
{"label": "Some detections available", "color": "#66b1ff"},
52-
]
67+
# Construct research website URL (without the name)
68+
research_url = (
69+
f"https://research.splunk.com/{detection_type}/{detection_id}/"
70+
)
5371

54-
layer_json["showTacticRowBackground"] = True
55-
layer_json["tacticRowBackground"] = "#dddddd"
56-
layer_json["sorting"] = 3
72+
links.append({"label": detection_name, "url": research_url})
5773

58-
for technique_id in mitre_techniques.keys():
5974
layer_technique = {
6075
"techniqueID": technique_id,
61-
"score": mitre_techniques[technique_id]["score"],
62-
"comment": "\n\n".join(mitre_techniques[technique_id]["file_paths"]),
76+
"score": data["score"],
77+
"enabled": True,
78+
"links": links,
6379
}
6480
layer_json["techniques"].append(layer_technique)
6581

contentctl/output/templates/transforms.j2

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ filename = {{ lookup.app_filename.name }}
77
collection = {{ lookup.collection }}
88
external_type = kvstore
99
{% endif %}
10-
{% if lookup.default_match is defined and lookup.default_match != None %}
11-
default_match = {{ lookup.default_match | lower }}
10+
{% if lookup.default_match != '' %}
11+
default_match = {{ lookup.default_match }}
1212
{% endif %}
1313
{% if lookup.case_sensitive_match is defined and lookup.case_sensitive_match != None %}
1414
case_sensitive_match = {{ lookup.case_sensitive_match | lower }}

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[tool.poetry]
22
name = "contentctl"
33

4-
version = "5.1.0"
4+
version = "5.2.0"
55

66
description = "Splunk Content Control Tool"
77
authors = ["STRT <[email protected]>"]
@@ -19,7 +19,7 @@ PyYAML = "^6.0.2"
1919
requests = "~2.32.3"
2020
pycvesearch = "^1.2"
2121
xmltodict = ">=0.13,<0.15"
22-
attackcti = "^0.4.0"
22+
attackcti = ">=0.5.4,<0.6"
2323
Jinja2 = "^3.1.4"
2424
questionary = "^2.0.1"
2525
docker = "^7.1.0"
@@ -30,10 +30,10 @@ tqdm = "^4.66.5"
3030
pygit2 = "^1.15.1"
3131
tyro = "^0.9.2"
3232
gitpython = "^3.1.43"
33-
setuptools = ">=69.5.1,<76.0.0"
33+
setuptools = ">=69.5.1,<79.0.0"
3434

3535
[tool.poetry.group.dev.dependencies]
36-
ruff = "^0.9.2"
36+
ruff = "^0.11.0"
3737

3838
[build-system]
3939
requires = ["poetry-core>=1.0.0"]

0 commit comments

Comments
 (0)