Skip to content

Commit 6c1bc7d

Browse files
authored
Merge branch 'main' into feature/risk-model-validation
2 parents 300e87f + a5b2817 commit 6c1bc7d

File tree

10 files changed

+112
-91
lines changed

10 files changed

+112
-91
lines changed

.github/ISSUE_TEMPLATE/bug_report.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ about: Create a report to help us improve contentctl
44
title: "[BUG]"
55
labels: bug
66
assignees: ''
7+
type: "Bug"
78

89
---
910

.github/ISSUE_TEMPLATE/feature_request.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ about: Suggest an idea for this project
44
title: ''
55
labels: enhancement
66
assignees: ''
7-
7+
type: "Feature"
88
---
99

1010
**Is your feature request related to a problem? Please describe.**

.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.11.0
11+
rev: v0.11.2
1212
hooks:
1313
- id: ruff
1414
args: [ --fix ]

contentctl/actions/release_notes.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
from contentctl.objects.config import release_notes
2-
from git import Repo
3-
import re
4-
import yaml
51
import pathlib
2+
import re
63
from typing import List, Union
74

5+
import yaml
6+
from git import Repo
7+
8+
from contentctl.objects.config import release_notes
9+
810

911
class ReleaseNotes:
1012
def create_notes(
@@ -171,13 +173,13 @@ def create_notes(
171173

172174
def release_notes(self, config: release_notes) -> None:
173175
### Remove hard coded path
174-
directories = [
175-
"detections/",
176-
"stories/",
177-
"macros/",
178-
"lookups/",
179-
"playbooks/",
180-
"ssa_detections/",
176+
directories: list[pathlib.Path] = [
177+
config.path / "detections",
178+
config.path / "stories",
179+
config.path / "macros",
180+
config.path / "lookups",
181+
config.path / "playbooks",
182+
config.path / "ssa_detections",
181183
]
182184

183185
repo = Repo(config.path)
@@ -229,7 +231,7 @@ def release_notes(self, config: release_notes) -> None:
229231
file_path = pathlib.Path(diff.a_path)
230232

231233
# Check if the file is in the specified directories
232-
if any(str(file_path).startswith(directory) for directory in directories):
234+
if any(file_path.is_relative_to(directory) for directory in directories):
233235
# Check if a file is Modified
234236
if diff.change_type == "M":
235237
modified_files.append(file_path)

contentctl/input/yml_reader.py

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from typing import Dict, Any
2-
import yaml
3-
import sys
41
import pathlib
2+
import sys
3+
from typing import Any, Dict
4+
5+
import yaml
56

67

78
class YmlReader:
@@ -13,40 +14,44 @@ def load_file(
1314
) -> Dict[str, Any]:
1415
try:
1516
file_handler = open(file_path, "r", encoding="utf-8")
17+
except OSError as exc:
18+
print(
19+
f"\nThere was an unrecoverable error when opening the file '{file_path}' - we will exit immediately:\n{str(exc)}"
20+
)
21+
sys.exit(1)
1622

1723
# The following code can help diagnose issues with duplicate keys or
1824
# poorly-formatted but still "compliant" YML. This code should be
1925
# enabled manually for debugging purposes. As such, strictyaml
2026
# library is intentionally excluded from the contentctl requirements
2127

28+
try:
2229
if STRICT_YML_CHECKING:
30+
# This is an extra level of verbose parsing that can be
31+
# enabled for debugging purpose. It is intentionally done in
32+
# addition to the regular yml parsing
2333
import strictyaml
2434

25-
try:
26-
strictyaml.dirty_load(file_handler.read(), allow_flow_style=True)
27-
file_handler.seek(0)
28-
except Exception as e:
29-
print(f"Error loading YML file {file_path}: {str(e)}")
30-
sys.exit(1)
31-
try:
32-
# Ideally we should use
33-
# from contentctl.actions.new_content import NewContent
34-
# and use NewContent.UPDATE_PREFIX,
35-
# but there is a circular dependency right now which makes that difficult.
36-
# We have instead hardcoded UPDATE_PREFIX
37-
UPDATE_PREFIX = "__UPDATE__"
38-
data = file_handler.read()
39-
if UPDATE_PREFIX in data:
40-
raise Exception(
41-
f"The file {file_path} contains the value '{UPDATE_PREFIX}'. Please fill out any unpopulated fields as required."
42-
)
43-
yml_obj = yaml.load(data, Loader=yaml.CSafeLoader)
44-
except yaml.YAMLError as exc:
45-
print(exc)
46-
sys.exit(1)
47-
48-
except OSError as exc:
49-
print(exc)
35+
strictyaml.dirty_load(file_handler.read(), allow_flow_style=True)
36+
file_handler.seek(0)
37+
38+
# Ideally we should use
39+
# from contentctl.actions.new_content import NewContent
40+
# and use NewContent.UPDATE_PREFIX,
41+
# but there is a circular dependency right now which makes that difficult.
42+
# We have instead hardcoded UPDATE_PREFIX
43+
UPDATE_PREFIX = "__UPDATE__"
44+
data = file_handler.read()
45+
if UPDATE_PREFIX in data:
46+
raise Exception(
47+
f"\nThe file {file_path} contains the value '{UPDATE_PREFIX}'. Please fill out any unpopulated fields as required."
48+
)
49+
yml_obj = yaml.load(data, Loader=yaml.CSafeLoader)
50+
51+
except yaml.YAMLError as exc:
52+
print(
53+
f"\nThere was an unrecoverable YML Parsing error when reading or parsing the file '{file_path}' - we will exit immediately:\n{str(exc)}"
54+
)
5055
sys.exit(1)
5156

5257
if add_fields is False:

contentctl/objects/abstract_security_content_objects/detection_abstract.py

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@
4343
DetectionStatus,
4444
NistCategory,
4545
ProvidingTechnology,
46+
RiskSeverity,
4647
)
4748
from contentctl.objects.integration_test import IntegrationTest
4849
from contentctl.objects.manual_test import ManualTest
49-
from contentctl.objects.rba import RBAObject
50+
from contentctl.objects.rba import RBAObject, RiskScoreValue_Type
5051
from contentctl.objects.security_content_object import SecurityContentObject
5152
from contentctl.objects.test_group import TestGroup
5253
from contentctl.objects.unit_test import UnitTest
@@ -66,6 +67,54 @@ class Detection_Abstract(SecurityContentObject):
6667
how_to_implement: str = Field(..., min_length=4)
6768
known_false_positives: str = Field(..., min_length=4)
6869
rba: Optional[RBAObject] = Field(default=None)
70+
71+
@computed_field
72+
@property
73+
def risk_score(self) -> RiskScoreValue_Type:
74+
# First get the maximum score associated with
75+
# a risk object. If there are no objects, then
76+
# we should throw an exception.
77+
if self.rba is None or len(self.rba.risk_objects) == 0:
78+
raise Exception(
79+
"There must be at least one Risk Object present to get Severity."
80+
)
81+
return max([risk_object.score for risk_object in self.rba.risk_objects])
82+
83+
@computed_field
84+
@property
85+
def severity(self) -> RiskSeverity:
86+
"""
87+
Severity is required for notables (but not risk objects).
88+
In the contentctl codebase, instead of requiring an additional
89+
field to be added to the YMLs, we derive the severity from the
90+
HIGHEST risk score of any risk object that is part of this detection.
91+
However, if a detection does not have a risk object but still has a notable,
92+
we will use a default value of high. This only impact Correlation searches. As
93+
TTP searches, which also generate notables, must also have risk object(s)
94+
"""
95+
try:
96+
risk_score = self.risk_score
97+
except Exception:
98+
# This object does not have any RBA objects,
99+
# hence no disk score is returned. So we will
100+
# return the defualt value of high
101+
return RiskSeverity.HIGH
102+
103+
if 0 <= risk_score <= 20:
104+
return RiskSeverity.INFORMATIONAL
105+
elif 20 < risk_score <= 40:
106+
return RiskSeverity.LOW
107+
elif 40 < risk_score <= 60:
108+
return RiskSeverity.MEDIUM
109+
elif 60 < risk_score <= 80:
110+
return RiskSeverity.HIGH
111+
elif 80 < risk_score <= 100:
112+
return RiskSeverity.CRITICAL
113+
else:
114+
raise Exception(
115+
f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}"
116+
)
117+
69118
explanation: None | str = Field(
70119
default=None,
71120
exclude=True, # Don't serialize this value when dumping the object
@@ -435,12 +484,10 @@ def serialize_model(self):
435484
"datamodel": self.datamodel,
436485
"source": self.source,
437486
"nes_fields": self.nes_fields,
487+
"rba": self.rba or {},
438488
}
439-
if self.rba is not None:
440-
model["risk_severity"] = self.rba.severity
441-
model["tags"]["risk_score"] = self.rba.risk_score
442-
else:
443-
model["tags"]["risk_score"] = 0
489+
if self.deployment.alert_action.notable:
490+
model["risk_severity"] = self.severity
444491

445492
# Only a subset of macro fields are required:
446493
all_macros: list[dict[str, str | list[str]]] = []

contentctl/objects/rba.py

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
from enum import Enum
55
from typing import Annotated, Set
66

7-
from pydantic import BaseModel, Field, computed_field, model_serializer
8-
9-
from contentctl.objects.enums import RiskSeverity
7+
from pydantic import BaseModel, Field, model_serializer
108

119
RiskScoreValue_Type = Annotated[int, Field(ge=1, le=100)]
1210

@@ -108,36 +106,6 @@ class RBAObject(BaseModel, ABC):
108106
risk_objects: Annotated[Set[RiskObject], Field(min_length=1)]
109107
threat_objects: Set[ThreatObject]
110108

111-
@computed_field
112-
@property
113-
def risk_score(self) -> RiskScoreValue_Type:
114-
# First get the maximum score associated with
115-
# a risk object. If there are no objects, then
116-
# we should throw an exception.
117-
if len(self.risk_objects) == 0:
118-
raise Exception(
119-
"There must be at least one Risk Object present to get Severity."
120-
)
121-
return max([risk_object.score for risk_object in self.risk_objects])
122-
123-
@computed_field
124-
@property
125-
def severity(self) -> RiskSeverity:
126-
if 0 <= self.risk_score <= 20:
127-
return RiskSeverity.INFORMATIONAL
128-
elif 20 < self.risk_score <= 40:
129-
return RiskSeverity.LOW
130-
elif 40 < self.risk_score <= 60:
131-
return RiskSeverity.MEDIUM
132-
elif 60 < self.risk_score <= 80:
133-
return RiskSeverity.HIGH
134-
elif 80 < self.risk_score <= 100:
135-
return RiskSeverity.CRITICAL
136-
else:
137-
raise Exception(
138-
f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}"
139-
)
140-
141109
@model_serializer
142110
def serialize_rba(self) -> dict[str, str | list[dict[str, str | int]]]:
143111
return {

contentctl/output/api_json_output.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
from __future__ import annotations
2+
23
from typing import TYPE_CHECKING
34

45
if TYPE_CHECKING:
6+
from contentctl.objects.baseline import Baseline
7+
from contentctl.objects.deployment import Deployment
58
from contentctl.objects.detection import Detection
9+
from contentctl.objects.investigation import Investigation
610
from contentctl.objects.lookup import Lookup
711
from contentctl.objects.macro import Macro
812
from contentctl.objects.story import Story
9-
from contentctl.objects.baseline import Baseline
10-
from contentctl.objects.investigation import Investigation
11-
from contentctl.objects.deployment import Deployment
1213

1314
import os
1415
import pathlib
@@ -42,6 +43,7 @@ def writeDetections(
4243
"search",
4344
"how_to_implement",
4445
"known_false_positives",
46+
"rba",
4547
"references",
4648
"datamodel",
4749
"macros",

contentctl/output/templates/savedsearches_detections.j2

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,10 @@ action.notable.param.nes_fields = {{ detection.nes_fields }}
6565
action.notable.param.rule_description = {{ detection.deployment.alert_action.notable.rule_description | custom_jinja2_enrichment_filter(detection) | escapeNewlines()}}
6666
action.notable.param.rule_title = {% if detection.type | lower == "correlation" %}RBA: {{ detection.deployment.alert_action.notable.rule_title | custom_jinja2_enrichment_filter(detection) }}{% else %}{{ detection.deployment.alert_action.notable.rule_title | custom_jinja2_enrichment_filter(detection) }}{% endif +%}
6767
action.notable.param.security_domain = {{ detection.tags.security_domain }}
68-
{% if detection.rba %}
69-
action.notable.param.severity = {{ detection.rba.severity }}
70-
{% else %}
71-
{# Correlations do not have detection.rba defined, but should get a default severity #}
72-
action.notable.param.severity = high
73-
{% endif %}
68+
action.notable.param.severity = {{ detection.severity }}
7469
{% endif %}
7570
{% if detection.deployment.alert_action.email %}
71+
action.email = 1
7672
action.email.subject.alert = {{ detection.deployment.alert_action.email.subject | custom_jinja2_enrichment_filter(detection) | escapeNewlines() }}
7773
action.email.to = {{ detection.deployment.alert_action.email.to }}
7874
action.email.message.alert = {{ detection.deployment.alert_action.email.message | custom_jinja2_enrichment_filter(detection) | escapeNewlines() }}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ gitpython = "^3.1.43"
3333
setuptools = ">=69.5.1,<79.0.0"
3434

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

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

0 commit comments

Comments
 (0)