Skip to content

Commit b00fa09

Browse files
[Hunt Tuning] Enforce STATS or KEEP functions in ES|QL hunting queries (#4157)
* enforcing aggregate or keep in ES|QL queries * Update hunting/definitions.py * Update hunting/definitions.py * Update hunting/definitions.py * updated capitalization of linting * updated raise value error * Update hunting/definitions.py * added note about stats in best practices (cherry picked from commit 4b4b2cc)
1 parent 163281c commit b00fa09

8 files changed

+44
-7
lines changed

hunting/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Otherwise, the names do not require the integration, since it is already annotat
4949
* Use `LIMIT` command to limit the number of results, depending on expected result volume
5050
* Filter as much as possible in `WHERE` command to reduce events needed to be processed
5151
* For `FROM` command for index patterns, be as specific as possible to reduce potential event matches that are irrelevant
52+
* Use `STATS` to aggregate results into a tabular format for optimization
5253

5354
### Field Usage
5455
Use standardized fields where possible to ensure that queries are compatible across different data environments and sources.

hunting/aws/queries/iam_assume_role_creation_with_attached_policy.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ from logs-aws.cloudtrail-*
2727
and aws.cloudtrail.request_parameters RLIKE ".*arn:aws:iam.*"
2828
| dissect aws.cloudtrail.request_parameters "%{}AWS\": \"arn:aws:iam::%{target_account_id}:"
2929
| where cloud.account.id != target_account_id
30+
| keep @timestamp, event.provider, event.action, aws.cloudtrail.request_parameters, target_account_id, cloud.account.id
3031
'''
3132
]

hunting/aws/queries/lambda_add_permissions_for_write_actions_to_function.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ from logs-aws.cloudtrail-*
2525
| dissect aws.cloudtrail.request_parameters "{%{?principal_key}=%{principal_id}, %{?function_name_key}=%{function_name}, %{?statement_key}=%{statement_value}, %{?action_key}=lambda:%{action_value}}"
2626
| eval write_action = (starts_with(action_value, "Invoke") or starts_with("Update", action_value) or starts_with("Put", action_value))
2727
| where write_action == true
28+
| keep @timestamp, principal_id, event.provider, event.action, aws.cloudtrail.request_parameters, principal_id, function_name, action_value, statement_value, write_action
2829
'''
2930
]

hunting/aws/queries/signin_single_factor_console_login_via_federated_session.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ from logs-aws.cloudtrail-*
2323
and aws.cloudtrail.user_identity.type == "FederatedUser"
2424
| dissect aws.cloudtrail.additional_eventdata "{%{?mobile_version_key}=%{mobile_version}, %{?mfa_used_key}=%{mfa_used}}"
2525
| where mfa_used == "No"
26+
| keep @timestamp, event.provider, event.action, aws.cloudtrail.event_type, aws.cloudtrail.user_identity.type, aws.cloudtrail.additional_eventdata, mobile_version, mfa_used
2627
''']

hunting/aws/queries/ssm_sendcommand_api_used_by_ec2_instance.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ from logs-aws.cloudtrail-*
2222
and aws.cloudtrail.user_identity.type == "AssumedRole"
2323
and event.action == "SendCommand"
2424
and user.id like "*:i-*"
25+
| keep @timestamp, event.provider, event.action, aws.cloudtrail.user_identity.type, user.id, aws.cloudtrail.request_parameters
2526
'''
2627
]

hunting/aws/queries/sts_suspicious_federated_temporary_credential_request.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ from logs-aws.cloudtrail-*
2727
| dissect aws.cloudtrail.request_parameters "{%{}policyArns=[%{policies_applied}]"
2828
| eval duration_minutes = to_integer(duration_requested) / 60
2929
| where (duration_minutes > 1440) or (policies_applied RLIKE ".*AdministratorAccess.*")
30+
| keep @timestamp, event.dataset, event.provider, event.action, aws.cloudtrail.request_parameters, user_name, duration_requested, duration_minutes, policies_applied
3031
''']

hunting/definitions.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
# 2.0; you may not use this file except in compliance with the Elastic License
44
# 2.0.
55

6+
import re
67
from dataclasses import dataclass, field
78
from pathlib import Path
8-
from typing import Optional
9+
from typing import Optional, List
910

1011
# Define the hunting directory path
1112
HUNTING_DIR = Path(__file__).parent
@@ -25,12 +26,40 @@ class Hunt:
2526
"""Dataclass to represent a hunt."""
2627
author: str
2728
description: str
28-
integration: list[str]
29+
integration: List[str]
2930
uuid: str
3031
name: str
31-
language: list[str]
32+
language: List[str]
3233
license: str
33-
query: list[str]
34-
notes: Optional[list[str]] = field(default_factory=list)
35-
mitre: list[str] = field(default_factory=list)
36-
references: Optional[list[str]] = field(default_factory=list)
34+
query: List[str]
35+
notes: Optional[List[str]] = field(default_factory=list)
36+
mitre: List[str] = field(default_factory=list)
37+
references: Optional[List[str]] = field(default_factory=list)
38+
39+
def __post_init__(self):
40+
"""Post-initialization to determine which validation to apply."""
41+
if not self.query:
42+
raise ValueError(f"Hunt: {self.name} - Query field must be provided.")
43+
44+
# Loop through each query in the array
45+
for idx, q in enumerate(self.query):
46+
query_start = q.strip().lower()
47+
48+
# Only validate queries that start with "from" (ESQL queries)
49+
if query_start.startswith("from"):
50+
self.validate_esql_query(q)
51+
52+
def validate_esql_query(self, query: str) -> None:
53+
"""Validation logic for ESQL."""
54+
query = query.lower()
55+
56+
if self.author == "Elastic":
57+
# Regex patterns for checking "stats by" and "| keep"
58+
stats_by_pattern = re.compile(r'\bstats\b.*?\bby\b', re.DOTALL)
59+
keep_pattern = re.compile(r'\| keep', re.DOTALL)
60+
61+
# Check if either "stats by" or "| keep" exists in the query
62+
if not stats_by_pattern.search(query) and not keep_pattern.search(query):
63+
raise ValueError(
64+
f"Hunt: {self.name} contains an ES|QL query that must contain either 'stats by' or 'keep' functions."
65+
)

hunting/okta/queries/defense_evasion_failed_oauth_access_token_retrieval_via_public_client_app.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,6 @@ from logs-okta.system*
3434
3535
// filter for scopes that are not implicitly granted
3636
and okta.outcome.reason == "no_matching_scope"
37+
38+
| keep @timestamp, event.action, okta.actor.type, okta.outcome.result, okta.outcome.reason, okta.actor.display_name
3739
''']

0 commit comments

Comments
 (0)