Skip to content

Commit 044d8fa

Browse files
Merge branch 'main' into 5164-rule-tuning-update-azure-m365-rule-names-and-file-paths
2 parents 4f4c374 + 793ecfe commit 044d8fa

File tree

398 files changed

+25420
-4229
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

398 files changed

+25420
-4229
lines changed

.github/workflows/esql-validation.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ name: ES|QL Validation
22
on:
33
pull_request:
44
branches: [ "*" ]
5-
paths:
6-
- 'rules/**/*.toml'
75
jobs:
86
build-and-validate:
97
runs-on: ubuntu-latest

.github/workflows/kibana-mitre-update.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818

1919
- name: Get MITRE Attack changed files
2020
id: changed-attack-files
21-
uses: tj-actions/changed-files@2f7c5bfce28377bc069a65ba478de0a74aa0ca32 # v46.0.1
21+
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5
2222
with:
2323
files: detection_rules/etc/attack-v*.json.gz
2424

.github/workflows/lock-versions.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,57 @@ jobs:
3737
pip cache purge
3838
pip install .[dev]
3939
40+
- name: Check out container repository
41+
env:
42+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id }}
43+
DR_API_KEY: ${{ secrets.dr_api_key }}
44+
if: ${{ !env.DR_CLOUD_ID && !env.DR_API_KEY }}
45+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
46+
with:
47+
path: elastic-container
48+
repository: peasead/elastic-container
49+
50+
- name: Build and run containers
51+
env:
52+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id }}
53+
DR_API_KEY: ${{ secrets.dr_api_key }}
54+
if: ${{ !env.DR_CLOUD_ID && !env.DR_API_KEY }}
55+
run: |
56+
cd elastic-container
57+
GENERATED_PASSWORD=$(openssl rand -base64 16)
58+
sed -i "s|changeme|$GENERATED_PASSWORD|" .env
59+
echo "::add-mask::$GENERATED_PASSWORD"
60+
echo "GENERATED_PASSWORD=$GENERATED_PASSWORD" >> $GITHUB_ENV
61+
set -x
62+
bash elastic-container.sh start
63+
64+
- name: Get API Key and setup auth
65+
env:
66+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id }}
67+
DR_API_KEY: ${{ secrets.dr_api_key }}
68+
DR_ELASTICSEARCH_URL: "https://localhost:9200"
69+
ES_USER: "elastic"
70+
ES_PASSWORD: ${{ env.GENERATED_PASSWORD }}
71+
if: ${{ !env.DR_CLOUD_ID && !env.DR_API_KEY }}
72+
run: |
73+
cd detection-rules
74+
response=$(curl -k -X POST -u "$ES_USER:$ES_PASSWORD" -H "Content-Type: application/json" -d '{
75+
"name": "tmp-api-key",
76+
"expiration": "1d"
77+
}' "$DR_ELASTICSEARCH_URL/_security/api_key")
78+
79+
DR_API_KEY=$(echo "$response" | jq -r '.encoded')
80+
echo "::add-mask::$DR_API_KEY"
81+
echo "DR_API_KEY=$DR_API_KEY" >> $GITHUB_ENV
82+
4083
- name: Build release package with navigator files
84+
env:
85+
DR_REMOTE_ESQL_VALIDATION: "true"
86+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id || '' }}
87+
DR_KIBANA_URL: ${{ secrets.dr_cloud_id == '' && 'https://localhost:5601' || '' }}
88+
DR_ELASTICSEARCH_URL: ${{ secrets.dr_cloud_id == '' && 'https://localhost:9200' || '' }}
89+
DR_API_KEY: ${{ secrets.dr_api_key || env.DR_API_KEY }}
90+
DR_IGNORE_SSL_ERRORS: ${{ secrets.dr_cloud_id == '' && 'true' || '' }}
4191
run: |
4292
python -m detection_rules dev build-release --generate-navigator
4393
@@ -56,6 +106,12 @@ jobs:
56106
- name: Lock the versions
57107
env:
58108
BRANCHES: "${{github.event.inputs.branches}}"
109+
DR_REMOTE_ESQL_VALIDATION: "true"
110+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id || '' }}
111+
DR_KIBANA_URL: ${{ secrets.dr_cloud_id == '' && 'https://localhost:5601' || '' }}
112+
DR_ELASTICSEARCH_URL: ${{ secrets.dr_cloud_id == '' && 'https://localhost:9200' || '' }}
113+
DR_API_KEY: ${{ secrets.dr_api_key || env.DR_API_KEY }}
114+
DR_IGNORE_SSL_ERRORS: ${{ secrets.dr_cloud_id == '' && 'true' || '' }}
59115
run: |
60116
./detection_rules/etc/lock-multiple.sh $BRANCHES
61117
git add detection_rules/etc/version.lock.json

.github/workflows/release-fleet.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,57 @@ jobs:
112112
git tag $RELEASE_TAG
113113
git push origin $RELEASE_TAG
114114
115+
- name: Check out container repository
116+
env:
117+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id }}
118+
DR_API_KEY: ${{ secrets.dr_api_key }}
119+
if: ${{ !env.DR_CLOUD_ID && !env.DR_API_KEY }}
120+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
121+
with:
122+
path: elastic-container
123+
repository: peasead/elastic-container
124+
125+
- name: Build and run containers
126+
env:
127+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id }}
128+
DR_API_KEY: ${{ secrets.dr_api_key }}
129+
if: ${{ !env.DR_CLOUD_ID && !env.DR_API_KEY }}
130+
run: |
131+
cd elastic-container
132+
GENERATED_PASSWORD=$(openssl rand -base64 16)
133+
sed -i "s|changeme|$GENERATED_PASSWORD|" .env
134+
echo "::add-mask::$GENERATED_PASSWORD"
135+
echo "GENERATED_PASSWORD=$GENERATED_PASSWORD" >> $GITHUB_ENV
136+
set -x
137+
bash elastic-container.sh start
138+
139+
- name: Get API Key and setup auth
140+
env:
141+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id }}
142+
DR_API_KEY: ${{ secrets.dr_api_key }}
143+
DR_ELASTICSEARCH_URL: "https://localhost:9200"
144+
ES_USER: "elastic"
145+
ES_PASSWORD: ${{ env.GENERATED_PASSWORD }}
146+
if: ${{ !env.DR_CLOUD_ID && !env.DR_API_KEY }}
147+
run: |
148+
cd detection-rules
149+
response=$(curl -k -X POST -u "$ES_USER:$ES_PASSWORD" -H "Content-Type: application/json" -d '{
150+
"name": "tmp-api-key",
151+
"expiration": "1d"
152+
}' "$DR_ELASTICSEARCH_URL/_security/api_key")
153+
154+
DR_API_KEY=$(echo "$response" | jq -r '.encoded')
155+
echo "::add-mask::$DR_API_KEY"
156+
echo "DR_API_KEY=$DR_API_KEY" >> $GITHUB_ENV
157+
115158
- name: Build release package
159+
env:
160+
DR_REMOTE_ESQL_VALIDATION: "true"
161+
DR_CLOUD_ID: ${{ secrets.dr_cloud_id || '' }}
162+
DR_KIBANA_URL: ${{ secrets.dr_cloud_id == '' && 'https://localhost:5601' || '' }}
163+
DR_ELASTICSEARCH_URL: ${{ secrets.dr_cloud_id == '' && 'https://localhost:9200' || '' }}
164+
DR_API_KEY: ${{ secrets.dr_api_key || env.DR_API_KEY }}
165+
DR_IGNORE_SSL_ERRORS: ${{ secrets.dr_cloud_id == '' && 'true' || '' }}
116166
run: |
117167
cd detection-rules
118168
python -m detection_rules dev build-release

detection_rules/atlas.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
# or more contributor license agreements. Licensed under the Elastic License
3+
# 2.0; you may not use this file except in compliance with the Elastic License
4+
# 2.0.
5+
6+
"""Mitre ATLAS info."""
7+
8+
from collections import OrderedDict
9+
from pathlib import Path
10+
from typing import Any
11+
12+
import requests
13+
import yaml
14+
from semver import Version
15+
16+
from .utils import cached, clear_caches, get_etc_path
17+
18+
ATLAS_FILE = get_etc_path(["ATLAS.yaml"])
19+
20+
# Maps tactic name to tactic ID (e.g., "Collection" -> "AML.TA0009")
21+
tactics_map: dict[str, str] = {}
22+
technique_lookup: dict[str, dict[str, Any]] = {}
23+
matrix: dict[str, list[str]] = {} # Maps tactic name to list of technique IDs
24+
25+
26+
@cached
27+
def get_atlas_file_path() -> Path:
28+
"""Get the path to the ATLAS YAML file."""
29+
if not ATLAS_FILE.exists():
30+
# Try to download it if it doesn't exist
31+
_ = download_atlas_data()
32+
return ATLAS_FILE
33+
34+
35+
def download_atlas_data(save: bool = True) -> dict[str, Any] | None:
36+
"""Download ATLAS data from MITRE."""
37+
url = "https://raw.githubusercontent.com/mitre-atlas/atlas-data/main/dist/ATLAS.yaml"
38+
r = requests.get(url, timeout=30)
39+
r.raise_for_status()
40+
atlas_data = yaml.safe_load(r.text)
41+
42+
if save:
43+
_ = ATLAS_FILE.write_text(r.text)
44+
print(f"Downloaded ATLAS data to {ATLAS_FILE}")
45+
46+
return atlas_data
47+
48+
49+
@cached
50+
def load_atlas_yaml() -> dict[str, Any]:
51+
"""Load ATLAS data from YAML file."""
52+
atlas_file = get_atlas_file_path()
53+
return yaml.safe_load(atlas_file.read_text())
54+
55+
56+
atlas = load_atlas_yaml()
57+
58+
# Extract version
59+
CURRENT_ATLAS_VERSION = atlas.get("version", "unknown")
60+
61+
# Process the ATLAS matrix
62+
# Look for the specific ATLAS matrix by ID, fall back to first matrix if not found
63+
ATLAS_MATRIX_ID = "ATLAS"
64+
matrix_data = None
65+
66+
if "matrices" in atlas and len(atlas["matrices"]) > 0:
67+
# Try to find the ATLAS matrix by ID
68+
for m in atlas["matrices"]:
69+
if m.get("id") == ATLAS_MATRIX_ID:
70+
matrix_data = m
71+
break
72+
73+
# Fall back to first matrix if ATLAS matrix not found by ID
74+
if matrix_data is None:
75+
matrix_data = atlas["matrices"][0]
76+
77+
if matrix_data is not None:
78+
# Build tactics map
79+
if "tactics" in matrix_data:
80+
for tactic in matrix_data["tactics"]:
81+
tactic_id = tactic["id"]
82+
tactic_name = tactic["name"]
83+
tactics_map[tactic_name] = tactic_id
84+
85+
# Build technique lookup and matrix
86+
if "techniques" in matrix_data:
87+
for technique in matrix_data["techniques"]:
88+
technique_id = technique["id"]
89+
technique_name = technique["name"]
90+
technique_tactics = technique.get("tactics", [])
91+
92+
# Store technique info
93+
technique_lookup[technique_id] = {
94+
"name": technique_name,
95+
"id": technique_id,
96+
"tactics": technique_tactics,
97+
}
98+
99+
# Build matrix: map tactic IDs to technique IDs
100+
for tech_tactic_id in technique_tactics:
101+
# Find tactic name from ID
102+
tech_tactic_name = next((name for name, tid in tactics_map.items() if tid == tech_tactic_id), None)
103+
if tech_tactic_name:
104+
if tech_tactic_name not in matrix:
105+
matrix[tech_tactic_name] = []
106+
if technique_id not in matrix[tech_tactic_name]:
107+
matrix[tech_tactic_name].append(technique_id)
108+
109+
# Sort matrix values
110+
for val in matrix.values():
111+
val.sort(key=lambda tid: technique_lookup.get(tid, {}).get("name", "").lower())
112+
113+
technique_lookup = OrderedDict(sorted(technique_lookup.items()))
114+
techniques = sorted({v["name"] for _, v in technique_lookup.items()})
115+
technique_id_list = [t for t in technique_lookup if "." not in t]
116+
sub_technique_id_list = [t for t in technique_lookup if "." in t]
117+
tactics = list(tactics_map)
118+
119+
120+
def refresh_atlas_data(save: bool = True) -> dict[str, Any] | None:
121+
"""Refresh ATLAS data from MITRE."""
122+
atlas_file = get_atlas_file_path()
123+
current_version_str = CURRENT_ATLAS_VERSION
124+
125+
try:
126+
current_version = Version.parse(current_version_str, optional_minor_and_patch=True)
127+
except (ValueError, TypeError):
128+
# If version parsing fails, download anyway
129+
current_version = Version.parse("0.0.0", optional_minor_and_patch=True)
130+
131+
# Get latest version from GitHub
132+
r = requests.get("https://api.github.com/repos/mitre-atlas/atlas-data/tags", timeout=30)
133+
r.raise_for_status()
134+
releases = r.json()
135+
if not releases:
136+
print("No releases found")
137+
return None
138+
139+
# Find latest version (tags might be like "v5.1.0" or "5.1.0")
140+
latest_release = None
141+
latest_version = current_version
142+
for release in releases:
143+
tag_name = release["name"].lstrip("v")
144+
try:
145+
ver = Version.parse(tag_name, optional_minor_and_patch=True)
146+
if ver > latest_version:
147+
latest_version = ver
148+
latest_release = release
149+
except (ValueError, TypeError):
150+
continue
151+
152+
if latest_release is None:
153+
print(f"No versions newer than the current detected: {current_version_str}")
154+
return None
155+
156+
download = f"https://raw.githubusercontent.com/mitre-atlas/atlas-data/{latest_release['name']}/dist/ATLAS.yaml"
157+
r = requests.get(download, timeout=30)
158+
r.raise_for_status()
159+
atlas_data = yaml.safe_load(r.text)
160+
161+
if save:
162+
_ = atlas_file.write_text(r.text)
163+
print(f"Replaced file: {atlas_file} with version {latest_version}")
164+
165+
# Clear cache to reload
166+
clear_caches()
167+
168+
return atlas_data
169+
170+
171+
def build_threat_map_entry(tactic_name: str, *technique_ids: str) -> dict[str, Any]:
172+
"""Build rule threat map from ATLAS technique IDs."""
173+
url_base = "https://atlas.mitre.org/{type}/{id}/"
174+
tactic_id = tactics_map.get(tactic_name)
175+
if not tactic_id:
176+
raise ValueError(f"Unknown ATLAS tactic: {tactic_name}")
177+
178+
tech_entries: dict[str, Any] = {}
179+
180+
def make_entry(_id: str) -> dict[str, Any]:
181+
tech_info = technique_lookup.get(_id)
182+
if not tech_info:
183+
raise ValueError(f"Unknown ATLAS technique ID: {_id}")
184+
return {
185+
"id": _id,
186+
"name": tech_info["name"],
187+
"reference": url_base.format(type="techniques", id=_id.replace(".", "/")),
188+
}
189+
190+
for tid in technique_ids:
191+
if tid not in technique_lookup:
192+
raise ValueError(f"Unknown ATLAS technique ID: {tid}")
193+
194+
tech_info = technique_lookup[tid]
195+
tech_tactic_ids = tech_info.get("tactics", [])
196+
if tactic_id not in tech_tactic_ids:
197+
raise ValueError(f"ATLAS technique ID: {tid} does not fall under tactic: {tactic_name}")
198+
199+
# Handle sub-techniques (e.g., AML.T0000.000)
200+
if "." in tid and tid.count(".") > 1:
201+
# This is a sub-technique
202+
parts = tid.rsplit(".", 1)
203+
parent_technique = parts[0]
204+
tech_entries.setdefault(parent_technique, make_entry(parent_technique))
205+
tech_entries[parent_technique].setdefault("subtechnique", []).append(make_entry(tid))
206+
else:
207+
tech_entries.setdefault(tid, make_entry(tid))
208+
209+
entry: dict[str, Any] = {
210+
"framework": "MITRE ATLAS",
211+
"tactic": {
212+
"id": tactic_id,
213+
"name": tactic_name,
214+
"reference": url_base.format(type="tactics", id=tactic_id),
215+
},
216+
}
217+
218+
if tech_entries:
219+
entry["technique"] = sorted(tech_entries.values(), key=lambda x: x["id"])
220+
221+
return entry

0 commit comments

Comments
 (0)