Skip to content

Commit a04ed82

Browse files
committed
created validation pipeline for ci/cd
1 parent a3b2b3c commit a04ed82

4 files changed

Lines changed: 122 additions & 67 deletions

File tree

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
11
title: Disable Script Block Logging
2-
id: dodea-sig-028-disable-script-block-logging
3-
status: experimental
4-
description: Detects registry modifications that disable PowerShell script block logging.
2+
id: SENT-DE-0001
3+
status: testing
4+
description: Detects registry modifications that disable PowerShell Script Block Logging.
55
author: Adam Ring
6-
date: '2025-08-07'
7-
tags:
8-
- attack.defense_evasion
9-
- attack.t1562.002
10-
- ckc.installation
6+
date: '2026-04-08'
7+
platform: windows
8+
query_language: kql
119
logsource:
1210
product: windows
1311
category: registry_event
14-
detection:
15-
selection:
16-
query: DeviceRegistryEvents | where RegistryKey has 'ScriptBlockLogging' and RegistryValueData =~ '0'
17-
condition: selection
18-
falsepositives:
19-
- None known
20-
level: medium
12+
query: |
13+
DeviceRegistryEvents
14+
| where RegistryKey has "ScriptBlockLogging"
15+
| where RegistryValueData =~ "0"
16+
severity: medium
17+
risk_score: 50
2118
tactics:
22-
- defense_evasion
19+
- defense_evasion
2320
techniques:
24-
- t1562
25-
lifecycle: experimental
21+
- t1562
22+
falsepositives:
23+
- Authorized administrative changes to PowerShell logging settings
24+
triage:
25+
- Confirm whether the registry change was approved by administrators
26+
- Review the initiating user account, host, and process context
27+
- Check for nearby PowerShell, LOLBin, or defense evasion activity on the same device
28+
validation:
29+
- Generate a test registry modification in a lab that disables Script Block Logging
30+
- Confirm the query returns the expected DeviceRegistryEvents record
31+
lifecycle: testing
32+
owner: detection-engineering
33+
tags:
34+
- attack.defense_evasion
35+
- attack.t1562.002
36+
- powershell
37+
- logging

detections/sentinel/execution/mshta-launching-script-or-powershell.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
title: Paste-and-Run Shell or Script Execution from Explorer
2-
id: SENT-EXEC-0005
1+
title: mshta launching script or powershell
2+
id: SENT-EXEC-0006
33
status: testing
44
description: Detects likely copy-paste execution behavior launched from Explorer or shell context into PowerShell, CMD, or common script interpreters.
55
author: Adam Ring

detections/sentinel/execution/oauth-redirection-abuse-followed-by-browser-download.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
title: OAuth Redirection Abuse Followed by Suspicious Execution
2-
id: SENT-EXEC-0006
2+
id: SENT-EXEC-0010
33
status: experimental
44
description: Detects suspicious OAuth authorization URL click activity followed closely by browser-driven download or suspicious endpoint execution.
55
author: Adam Ring

tests/validation/validate_detections.py

Lines changed: 89 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
DETECTIONS_ROOT = Path("detections/sentinel")
66

7-
REQUIRED_FIELDS = [
7+
# Full schema for active detections
8+
REQUIRED_ACTIVE_FIELDS = [
89
"title",
910
"id",
1011
"status",
@@ -27,6 +28,18 @@
2728
"tags",
2829
]
2930

31+
# Lighter schema for deprecated detections
32+
REQUIRED_DEPRECATED_FIELDS = [
33+
"title",
34+
"id",
35+
"status",
36+
"description",
37+
"author",
38+
"date",
39+
"logsource",
40+
"lifecycle",
41+
]
42+
3043
ALLOWED_STATUS = {"experimental", "testing", "stable", "production", "deprecated"}
3144
ALLOWED_LIFECYCLE = {"experimental", "testing", "production", "deprecated"}
3245
ALLOWED_SEVERITY = {"low", "medium", "high", "critical"}
@@ -37,26 +50,57 @@ def load_yaml(path: Path):
3750
return yaml.safe_load(f)
3851

3952

40-
def validate_file(path: Path):
53+
def is_deprecated_rule(path: Path, data: dict) -> bool:
54+
if "deprecated" in path.parts:
55+
return True
56+
lifecycle = str(data.get("lifecycle", "")).strip().lower()
57+
status = str(data.get("status", "")).strip().lower()
58+
return lifecycle == "deprecated" or status == "deprecated"
59+
60+
61+
def validate_common_fields(path: Path, data: dict):
4162
errors = []
4263

43-
try:
44-
data = load_yaml(path)
45-
except Exception as exc:
46-
return [f"{path}: YAML parse error: {exc}"]
64+
status = str(data.get("status", "")).strip().lower()
65+
lifecycle = str(data.get("lifecycle", "")).strip().lower()
66+
title = str(data.get("title", "")).strip()
67+
rule_id = str(data.get("id", "")).strip()
4768

48-
if not isinstance(data, dict):
49-
return [f"{path}: root YAML object must be a dictionary"]
69+
if title == "":
70+
errors.append(f"{path}: 'title' must not be empty")
71+
72+
deprecated = is_deprecated_rule(path, data)
73+
74+
if not deprecated and not rule_id.startswith("SENT-"):
75+
errors.append(f"{path}: 'id' should start with 'SENT-'")
76+
77+
if status and status not in ALLOWED_STATUS:
78+
errors.append(
79+
f"{path}: invalid status '{data.get('status')}'. Allowed: {sorted(ALLOWED_STATUS)}"
80+
)
5081

51-
for field in REQUIRED_FIELDS:
82+
if lifecycle and lifecycle not in ALLOWED_LIFECYCLE:
83+
errors.append(
84+
f"{path}: invalid lifecycle '{data.get('lifecycle')}'. Allowed: {sorted(ALLOWED_LIFECYCLE)}"
85+
)
86+
87+
if "logsource" in data and not isinstance(data.get("logsource"), dict):
88+
errors.append(f"{path}: 'logsource' must be a dictionary")
89+
90+
return errors
91+
92+
93+
def validate_active_rule(path: Path, data: dict):
94+
errors = []
95+
96+
for field in REQUIRED_ACTIVE_FIELDS:
5297
if field not in data:
5398
errors.append(f"{path}: missing required field '{field}'")
5499

55100
if errors:
56101
return errors
57102

58-
if not isinstance(data.get("logsource"), dict):
59-
errors.append(f"{path}: 'logsource' must be a dictionary")
103+
errors.extend(validate_common_fields(path, data))
60104

61105
if not isinstance(data.get("tags"), list):
62106
errors.append(f"{path}: 'tags' must be a list")
@@ -76,20 +120,7 @@ def validate_file(path: Path):
76120
if not isinstance(data.get("validation"), list):
77121
errors.append(f"{path}: 'validation' must be a list")
78122

79-
status = str(data.get("status", "")).strip().lower()
80-
lifecycle = str(data.get("lifecycle", "")).strip().lower()
81123
severity = str(data.get("severity", "")).strip().lower()
82-
83-
if status not in ALLOWED_STATUS:
84-
errors.append(
85-
f"{path}: invalid status '{data.get('status')}'. Allowed: {sorted(ALLOWED_STATUS)}"
86-
)
87-
88-
if lifecycle not in ALLOWED_LIFECYCLE:
89-
errors.append(
90-
f"{path}: invalid lifecycle '{data.get('lifecycle')}'. Allowed: {sorted(ALLOWED_LIFECYCLE)}"
91-
)
92-
93124
if severity not in ALLOWED_SEVERITY:
94125
errors.append(
95126
f"{path}: invalid severity '{data.get('severity')}'. Allowed: {sorted(ALLOWED_SEVERITY)}"
@@ -101,31 +132,45 @@ def validate_file(path: Path):
101132
elif not 0 <= risk_score <= 100:
102133
errors.append(f"{path}: 'risk_score' must be between 0 and 100")
103134

104-
title = str(data.get("title", "")).strip()
105-
rule_id = str(data.get("id", "")).strip()
106135
query = str(data.get("query", "")).strip()
136+
if not query:
137+
errors.append(f"{path}: 'query' must not be empty")
107138

108-
if not title:
109-
errors.append(f"{path}: 'title' must not be empty")
139+
return errors
110140

111-
if not rule_id.startswith("SENT-"):
112-
errors.append(f"{path}: 'id' should start with 'SENT-'")
113141

114-
if not query:
115-
errors.append(f"{path}: 'query' must not be empty")
142+
def validate_deprecated_rule(path: Path, data: dict):
143+
errors = []
144+
145+
for field in REQUIRED_DEPRECATED_FIELDS:
146+
if field not in data:
147+
errors.append(f"{path}: missing required field '{field}'")
116148

117-
relative_parts = path.relative_to(DETECTIONS_ROOT).parts
118-
if len(relative_parts) >= 2:
119-
tactic_folder = relative_parts[0]
120-
tags = [str(t).strip().lower() for t in data.get("tags", [])]
121-
if tactic_folder not in tags:
122-
errors.append(
123-
f"{path}: expected tactic folder '{tactic_folder}' to appear in tags"
124-
)
149+
if errors:
150+
return errors
151+
152+
errors.extend(validate_common_fields(path, data))
125153

126154
return errors
127155

128156

157+
def validate_file(path: Path):
158+
try:
159+
data = load_yaml(path)
160+
except Exception as exc:
161+
return [f"{path}: YAML parse error: {exc}"], None
162+
163+
if not isinstance(data, dict):
164+
return [f"{path}: root YAML object must be a dictionary"], None
165+
166+
deprecated = is_deprecated_rule(path, data)
167+
168+
if deprecated:
169+
return validate_deprecated_rule(path, data), data
170+
171+
return validate_active_rule(path, data), data
172+
173+
129174
def main():
130175
if not DETECTIONS_ROOT.exists():
131176
print(f"Detection root not found: {DETECTIONS_ROOT}")
@@ -142,21 +187,19 @@ def main():
142187
seen_titles = {}
143188

144189
for path in detection_files:
145-
errors = validate_file(path)
190+
errors, data = validate_file(path)
146191
all_errors.extend(errors)
147192

148-
try:
149-
data = load_yaml(path)
150-
if isinstance(data, dict):
193+
if isinstance(data, dict):
194+
deprecated = is_deprecated_rule(path, data)
195+
if not deprecated:
151196
rule_id = str(data.get("id", "")).strip()
152197
title = str(data.get("title", "")).strip().lower()
153198

154199
if rule_id:
155200
seen_ids.setdefault(rule_id, []).append(str(path))
156201
if title:
157202
seen_titles.setdefault(title, []).append(str(path))
158-
except Exception:
159-
pass
160203

161204
for rule_id, paths in seen_ids.items():
162205
if len(paths) > 1:

0 commit comments

Comments
 (0)