Skip to content

Commit 7646c24

Browse files
authored
Merge pull request #325 from splunk/exception_on_extra_fields
Exception on extra fields
2 parents e5c150d + 8b86914 commit 7646c24

34 files changed

+174
-160
lines changed

contentctl/actions/new_content.py

Lines changed: 101 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
2-
31
from dataclasses import dataclass
42
import questionary
53
from typing import Any
@@ -11,67 +9,108 @@
119
import pathlib
1210
from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import SecurityContentObject_Abstract
1311
from contentctl.output.yml_writer import YmlWriter
14-
12+
from contentctl.objects.enums import AssetType
13+
from contentctl.objects.constants import SES_OBSERVABLE_TYPE_MAPPING, SES_OBSERVABLE_ROLE_MAPPING
1514
class NewContent:
15+
UPDATE_PREFIX = "__UPDATE__"
16+
17+
DEFAULT_DRILLDOWN_DEF = [
18+
{
19+
"name": f'View the detection results for - "${UPDATE_PREFIX}FIRST_RISK_OBJECT$" and "${UPDATE_PREFIX}SECOND_RISK_OBJECT$"',
20+
"search": f'%original_detection_search% | search "${UPDATE_PREFIX}FIRST_RISK_OBJECT = "${UPDATE_PREFIX}FIRST_RISK_OBJECT$" second_observable_type_here = "${UPDATE_PREFIX}SECOND_RISK_OBJECT$"',
21+
"earliest_offset": '$info_min_time$',
22+
"latest_offset": '$info_max_time$'
23+
},
24+
{
25+
"name": f'View risk events for the last 7 days for - "${UPDATE_PREFIX}FIRST_RISK_OBJECT$" and "${UPDATE_PREFIX}SECOND_RISK_OBJECT$"',
26+
"search": f'| from datamodel Risk.All_Risk | search normalized_risk_object IN ("${UPDATE_PREFIX}FIRST_RISK_OBJECT$", "${UPDATE_PREFIX}SECOND_RISK_OBJECT$") starthoursago=168 | stats count min(_time) as firstTime max(_time) as lastTime values(search_name) as "Search Name" values(risk_message) as "Risk Message" values(analyticstories) as "Analytic Stories" values(annotations._all) as "Annotations" values(annotations.mitre_attack.mitre_tactic) as "ATT&CK Tactics" by normalized_risk_object | `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)`',
27+
"earliest_offset": '$info_min_time$',
28+
"latest_offset": '$info_max_time$'
29+
}
30+
]
31+
1632

17-
def buildDetection(self)->dict[str,Any]:
33+
def buildDetection(self) -> tuple[dict[str, Any], str]:
1834
questions = NewContentQuestions.get_questions_detection()
19-
answers: dict[str,str] = questionary.prompt(
20-
questions,
21-
kbi_msg="User did not answer all of the prompt questions. Exiting...")
35+
answers: dict[str, str] = questionary.prompt(
36+
questions,
37+
kbi_msg="User did not answer all of the prompt questions. Exiting...",
38+
)
2239
if not answers:
2340
raise ValueError("User didn't answer one or more questions!")
24-
answers.update(answers)
25-
answers['name'] = answers['detection_name']
26-
del answers['detection_name']
27-
answers['id'] = str(uuid.uuid4())
28-
answers['version'] = 1
29-
answers['date'] = datetime.today().strftime('%Y-%m-%d')
30-
answers['author'] = answers['detection_author']
31-
del answers['detection_author']
32-
answers['data_source'] = answers['data_source']
33-
answers['type'] = answers['detection_type']
34-
del answers['detection_type']
35-
answers['status'] = "production" #start everything as production since that's what we INTEND the content to become
36-
answers['description'] = 'UPDATE_DESCRIPTION'
37-
file_name = answers['name'].replace(' ', '_').replace('-','_').replace('.','_').replace('/','_').lower()
38-
answers['search'] = answers['detection_search'] + ' | `' + file_name + '_filter`'
39-
del answers['detection_search']
40-
answers['how_to_implement'] = 'UPDATE_HOW_TO_IMPLEMENT'
41-
answers['known_false_positives'] = 'UPDATE_KNOWN_FALSE_POSITIVES'
42-
answers['references'] = ['REFERENCE']
43-
answers['tags'] = dict()
44-
answers['tags']['analytic_story'] = ['UPDATE_STORY_NAME']
45-
answers['tags']['asset_type'] = 'UPDATE asset_type'
46-
answers['tags']['confidence'] = 'UPDATE value between 1-100'
47-
answers['tags']['impact'] = 'UPDATE value between 1-100'
48-
answers['tags']['message'] = 'UPDATE message'
49-
answers['tags']['mitre_attack_id'] = [x.strip() for x in answers['mitre_attack_ids'].split(',')]
50-
answers['tags']['observable'] = [{'name': 'UPDATE', 'type': 'UPDATE', 'role': ['UPDATE']}]
51-
answers['tags']['product'] = ['Splunk Enterprise','Splunk Enterprise Security','Splunk Cloud']
52-
answers['tags']['required_fields'] = ['UPDATE']
53-
answers['tags']['risk_score'] = 'UPDATE (impact * confidence)/100'
54-
answers['tags']['security_domain'] = answers['security_domain']
55-
del answers["security_domain"]
56-
answers['tags']['cve'] = ['UPDATE WITH CVE(S) IF APPLICABLE']
57-
58-
#generate the tests section
59-
answers['tests'] = [
60-
{
61-
'name': "True Positive Test",
62-
'attack_data': [
63-
{
64-
'data': "https://github.com/splunk/contentctl/wiki",
65-
"sourcetype": "UPDATE SOURCETYPE",
66-
"source": "UPDATE SOURCE"
67-
}
68-
]
69-
}
70-
]
71-
del answers["mitre_attack_ids"]
72-
return answers
7341

74-
def buildStory(self)->dict[str,Any]:
42+
data_source_field = (
43+
answers["data_source"] if len(answers["data_source"]) > 0 else [f"{NewContent.UPDATE_PREFIX} zero or more data_sources"]
44+
)
45+
file_name = (
46+
answers["detection_name"]
47+
.replace(" ", "_")
48+
.replace("-", "_")
49+
.replace(".", "_")
50+
.replace("/", "_")
51+
.lower()
52+
)
53+
54+
#Minimum lenght for a mitre tactic is 5 characters: T1000
55+
if len(answers["mitre_attack_ids"]) >= 5:
56+
mitre_attack_ids = [x.strip() for x in answers["mitre_attack_ids"].split(",")]
57+
else:
58+
#string was too short, so just put a placeholder
59+
mitre_attack_ids = [f"{NewContent.UPDATE_PREFIX} zero or more mitre_attack_ids"]
60+
61+
output_file_answers: dict[str, Any] = {
62+
"name": answers["detection_name"],
63+
"id": str(uuid.uuid4()),
64+
"version": 1,
65+
"date": datetime.today().strftime("%Y-%m-%d"),
66+
"author": answers["detection_author"],
67+
"status": "production", # start everything as production since that's what we INTEND the content to become
68+
"type": answers["detection_type"],
69+
"description": f"{NewContent.UPDATE_PREFIX} by providing a description of your search",
70+
"data_source": data_source_field,
71+
"search": f"{answers['detection_search']} | `{file_name}_filter`",
72+
"how_to_implement": f"{NewContent.UPDATE_PREFIX} how to implement your search",
73+
"known_false_positives": f"{NewContent.UPDATE_PREFIX} known false positives for your search",
74+
"references": [f"{NewContent.UPDATE_PREFIX} zero or more http references to provide more information about your search"],
75+
"drilldown_searches": NewContent.DEFAULT_DRILLDOWN_DEF,
76+
"tags": {
77+
"analytic_story": [f"{NewContent.UPDATE_PREFIX} by providing zero or more analytic stories"],
78+
"asset_type": f"{NewContent.UPDATE_PREFIX} by providing and asset type from {list(AssetType._value2member_map_)}",
79+
"confidence": f"{NewContent.UPDATE_PREFIX} by providing a value between 1-100",
80+
"impact": f"{NewContent.UPDATE_PREFIX} by providing a value between 1-100",
81+
"message": f"{NewContent.UPDATE_PREFIX} by providing a risk message. Fields in your search results can be referenced using $fieldName$",
82+
"mitre_attack_id": mitre_attack_ids,
83+
"observable": [
84+
{"name": f"{NewContent.UPDATE_PREFIX} the field name of the observable. This is a field that exists in your search results.", "type": f"{NewContent.UPDATE_PREFIX} the type of your observable from the list {list(SES_OBSERVABLE_TYPE_MAPPING.keys())}.", "role": [f"{NewContent.UPDATE_PREFIX} the role from the list {list(SES_OBSERVABLE_ROLE_MAPPING.keys())}"]}
85+
],
86+
"product": [
87+
"Splunk Enterprise",
88+
"Splunk Enterprise Security",
89+
"Splunk Cloud",
90+
],
91+
"security_domain": answers["security_domain"],
92+
"cve": [f"{NewContent.UPDATE_PREFIX} with CVE(s) if applicable"],
93+
},
94+
"tests": [
95+
{
96+
"name": "True Positive Test",
97+
"attack_data": [
98+
{
99+
"data": f"{NewContent.UPDATE_PREFIX} the data file to replay. Go to https://github.com/splunk/contentctl/wiki for information about the format of this field",
100+
"sourcetype": f"{NewContent.UPDATE_PREFIX} the sourcetype of your data file.",
101+
"source": f"{NewContent.UPDATE_PREFIX} the source of your datafile",
102+
}
103+
],
104+
}
105+
],
106+
}
107+
108+
if answers["detection_type"] not in ["TTP", "Anomaly", "Correlation"]:
109+
del output_file_answers["drilldown_searches"]
110+
111+
return output_file_answers, answers['detection_kind']
112+
113+
def buildStory(self) -> dict[str, Any]:
75114
questions = NewContentQuestions.get_questions_story()
76115
answers = questionary.prompt(
77116
questions,
@@ -96,12 +135,11 @@ def buildStory(self)->dict[str,Any]:
96135
del answers['usecase']
97136
answers['tags']['cve'] = ['UPDATE WITH CVE(S) IF APPLICABLE']
98137
return answers
99-
100138

101139
def execute(self, input_dto: new) -> None:
102140
if input_dto.type == NewContentType.detection:
103-
content_dict = self.buildDetection()
104-
subdirectory = pathlib.Path('detections') / content_dict.pop('detection_kind')
141+
content_dict, detection_kind = self.buildDetection()
142+
subdirectory = pathlib.Path('detections') / detection_kind
105143
elif input_dto.type == NewContentType.story:
106144
content_dict = self.buildStory()
107145
subdirectory = pathlib.Path('stories')
@@ -111,23 +149,20 @@ def execute(self, input_dto: new) -> None:
111149
full_output_path = input_dto.path / subdirectory / SecurityContentObject_Abstract.contentNameToFileName(content_dict.get('name'))
112150
YmlWriter.writeYmlFile(str(full_output_path), content_dict)
113151

114-
115-
116152
def writeObjectNewContent(self, object: dict, subdirectory_name: str, type: NewContentType) -> None:
117153
if type == NewContentType.detection:
118154
file_path = os.path.join(self.output_path, 'detections', subdirectory_name, self.convertNameToFileName(object['name'], object['tags']['product']))
119155
output_folder = pathlib.Path(self.output_path)/'detections'/subdirectory_name
120-
#make sure the output folder exists for this detection
156+
# make sure the output folder exists for this detection
121157
output_folder.mkdir(exist_ok=True)
122158

123159
YmlWriter.writeDetection(file_path, object)
124160
print("Successfully created detection " + file_path)
125-
161+
126162
elif type == NewContentType.story:
127163
file_path = os.path.join(self.output_path, 'stories', self.convertNameToFileName(object['name'], object['tags']['product']))
128164
YmlWriter.writeStory(file_path, object)
129165
print("Successfully created story " + file_path)
130-
166+
131167
else:
132168
raise(Exception(f"Object Must be Story or Detection, but is not: {object}"))
133-

contentctl/contentctl.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def main():
154154

155155
else:
156156
#The file exists, so load it up!
157-
config_obj = YmlReader().load_file(configFile)
157+
config_obj = YmlReader().load_file(configFile,add_fields=False)
158158
t = test.model_validate(config_obj)
159159
except Exception as e:
160160
print(f"Error validating 'contentctl.yml':\n{str(e)}")

contentctl/helper/utils.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -247,20 +247,6 @@ def validate_git_pull_request(repo_path: str, pr_number: int) -> str:
247247

248248
return hash
249249

250-
# @staticmethod
251-
# def check_required_fields(
252-
# thisField: str, definedFields: dict, requiredFields: list[str]
253-
# ):
254-
# missing_fields = [
255-
# field for field in requiredFields if field not in definedFields
256-
# ]
257-
# if len(missing_fields) > 0:
258-
# raise (
259-
# ValueError(
260-
# f"Could not validate - please resolve other errors resulting in missing fields {missing_fields}"
261-
# )
262-
# )
263-
264250
@staticmethod
265251
def verify_file_exists(
266252
file_path: str, verbose_print=False, timeout_seconds: int = 10

contentctl/input/new_content_questions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def get_questions_detection(cls) -> list[dict[str,Any]]:
5757
"type": "text",
5858
"message": "enter search (spl)",
5959
"name": "detection_search",
60-
"default": "| UPDATE_SPL",
60+
"default": "| __UPDATE__ SPL",
6161
},
6262
{
6363
"type": "text",

contentctl/input/yml_reader.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
from typing import Dict, Any
2-
32
import yaml
4-
5-
63
import sys
74
import pathlib
85

96
class YmlReader():
107

118
@staticmethod
12-
def load_file(file_path: pathlib.Path, add_fields=True, STRICT_YML_CHECKING=False) -> Dict[str,Any]:
9+
def load_file(file_path: pathlib.Path, add_fields:bool=True, STRICT_YML_CHECKING:bool=False) -> Dict[str,Any]:
1310
try:
1411
file_handler = open(file_path, 'r', encoding="utf-8")
1512

@@ -27,8 +24,16 @@ def load_file(file_path: pathlib.Path, add_fields=True, STRICT_YML_CHECKING=Fals
2724
print(f"Error loading YML file {file_path}: {str(e)}")
2825
sys.exit(1)
2926
try:
30-
#yml_obj = list(yaml.safe_load_all(file_handler))[0]
31-
yml_obj = yaml.load(file_handler, Loader=yaml.CSafeLoader)
27+
#Ideally we should use
28+
# from contentctl.actions.new_content import NewContent
29+
# and use NewContent.UPDATE_PREFIX,
30+
# but there is a circular dependency right now which makes that difficult.
31+
# We have instead hardcoded UPDATE_PREFIX
32+
UPDATE_PREFIX = "__UPDATE__"
33+
data = file_handler.read()
34+
if UPDATE_PREFIX in data:
35+
raise Exception(f"The file {file_path} contains the value '{UPDATE_PREFIX}'. Please fill out any unpopulated fields as required.")
36+
yml_obj = yaml.load(data, Loader=yaml.CSafeLoader)
3237
except yaml.YAMLError as exc:
3338
print(exc)
3439
sys.exit(1)

contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@
3333

3434
# TODO (#266): disable the use_enum_values configuration
3535
class SecurityContentObject_Abstract(BaseModel, abc.ABC):
36-
model_config = ConfigDict(use_enum_values=True,validate_default=True)
37-
36+
model_config = ConfigDict(use_enum_values=True,validate_default=True,extra="forbid")
3837
name: str = Field(...,max_length=99)
3938
author: str = Field(...,max_length=255)
4039
date: datetime.date = Field(...)

contentctl/objects/alert_action.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from __future__ import annotations
2-
from pydantic import BaseModel, model_serializer
2+
from pydantic import BaseModel, model_serializer, ConfigDict
33
from typing import Optional
44

55
from contentctl.objects.deployment_email import DeploymentEmail
@@ -9,6 +9,7 @@
99
from contentctl.objects.deployment_phantom import DeploymentPhantom
1010

1111
class AlertAction(BaseModel):
12+
model_config = ConfigDict(extra="forbid")
1213
email: Optional[DeploymentEmail] = None
1314
notable: Optional[DeploymentNotable] = None
1415
rba: Optional[DeploymentRBA] = DeploymentRBA()

contentctl/objects/atomic.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class InputArgumentType(StrEnum):
4141
Url = "Url"
4242

4343
class AtomicExecutor(BaseModel):
44+
model_config = ConfigDict(extra="forbid")
4445
name: str
4546
elevation_required: Optional[bool] = False #Appears to be optional
4647
command: Optional[str] = None

contentctl/objects/base_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Union
33
from abc import ABC, abstractmethod
44

5-
from pydantic import BaseModel
5+
from pydantic import BaseModel,ConfigDict
66

77
from contentctl.objects.base_test_result import BaseTestResult
88

@@ -21,6 +21,7 @@ def __str__(self) -> str:
2121

2222
# TODO (#224): enforce distinct test names w/in detections
2323
class BaseTest(BaseModel, ABC):
24+
model_config = ConfigDict(extra="forbid")
2425
"""
2526
A test case for a detection
2627
"""

contentctl/objects/baseline.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
from __future__ import annotations
3-
from typing import Annotated, Optional, List,Any
4-
from pydantic import field_validator, ValidationInfo, Field, model_serializer
3+
from typing import Annotated, List,Any
4+
from pydantic import field_validator, ValidationInfo, Field, model_serializer, computed_field
55
from contentctl.objects.deployment import Deployment
66
from contentctl.objects.security_content_object import SecurityContentObject
77
from contentctl.objects.enums import DataModel
@@ -15,7 +15,6 @@
1515
class Baseline(SecurityContentObject):
1616
name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
1717
type: Annotated[str,Field(pattern="^Baseline$")] = Field(...)
18-
datamodel: Optional[List[DataModel]] = None
1918
search: str = Field(..., min_length=4)
2019
how_to_implement: str = Field(..., min_length=4)
2120
known_false_positives: str = Field(..., min_length=4)
@@ -34,6 +33,10 @@ def get_conf_stanza_name(self, app:CustomApp)->str:
3433
def getDeployment(cls, v:Any, info:ValidationInfo)->Deployment:
3534
return Deployment.getDeployment(v,info)
3635

36+
@computed_field
37+
@property
38+
def datamodel(self) -> List[DataModel]:
39+
return [dm for dm in DataModel if dm.value in self.search]
3740

3841
@model_serializer
3942
def serialize_model(self):

0 commit comments

Comments
 (0)