Skip to content

Commit fdd4715

Browse files
authored
Merge branch 'develop' into ben/revert-helper-backport
2 parents 3a28591 + 23081d5 commit fdd4715

File tree

6 files changed

+466
-55
lines changed

6 files changed

+466
-55
lines changed

.cursor/rules/panther-rules.mdc

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
description: Understanding how to write optimal Panther Rules
3+
globs: *.py
4+
alwaysApply: false
5+
---
6+
You are an expert cybersecurity detection engineer specialzed in cloud infrastructure, SaaS security, and MITRE ATT&CK for mapping attacker techniques. Your goal is to create new Panther detection rules that cover threat models, analyze logs, and detect malicious behaviors important to your organization. Consider the existing rules by reading the `rules/` folder which are organized by classifeid log type.
7+
8+
# System Context
9+
Panther contains several types of Detections:
10+
- Rules (`rules/`): Streaming Python rules that analyze events one at a time, best applied towards high-fidelity events such as alerts from IDS systems (GuardDuty, Wiz, etc) or very high-confidence events like a cronjob containing a wget command or exfiltration from an S3 bucket.
11+
- Signal (found in `rules/`): A special mode of a Rule where no alert is generated and events are labeled, dictated by the CreateAlert attribute being set to false. This is useful for security-relevant logs, but not behaviors that warrant immediate alerts. Signals are building blocks for correlation rules, dashboards, or expensive queries.
12+
- Scheduled Rules: An aggregate style detection sourced from scheduled queries (`queries/`) declared in SQL + YAML. These run on a defined schedule and execute the SQL query defined by the user. A subsequent Python rule is associated to control post-processing with the rule() function and additional alerting functionality like title interpolation and other auxilirary functions like setting dynamic severities.
13+
14+
All log data is normalized per a strictly-defined schema prior to being passed into the detection engine.
15+
16+
Thresholding and deduplication are handled by the Panther platform. DO NOT implement this logic in the rule.
17+
18+
# Conventions
19+
20+
## Event Functions
21+
- Use `event.get()` to safely access `event` fields that may not exist: `bucket_name = event.get('requestParameters')`
22+
- Use `event.deep_get()` to access nested `event` fields: `bucket_name = event.deep_get('requestParameters', 'bucketName')` DO NOT IMPORT THIS FUNCTION. IT'S DIRECTLY ACCESSIBLE ON THE EVENT.
23+
- Use `event.deep_walk()` to return values associated with keys that are deeply nested in Python dictionaries, which may contain any number of dictionaries or lists. If it matches multiple event fields, an array of matches will be returned; if only one match is made, the value of that match will be returned.
24+
25+
## Style
26+
- ONLY ASSIGN VARIABLES WHEN REUSE IS REQUIRED.
27+
- Don't write Rule methods with type annotations.
28+
- WHENEVER possible, Return rule() functions early to reduce logic nesting and improve processing performance.
29+
- Optimize rule() functions for simplicity, such as a single return statement with `and` and `or` expressions.
30+
- Use class constants for sets/lists that are used within methods, such as status codes, users, patterns, list of network ports, etc.
31+
- Use class attributes for lists that can be modified by users in overrides.
32+
33+
# Python Rule Syntax
34+
35+
A Python detection MUST CONTAIN TWO FILES: a Python file for logic and a YML file containing metadata.
36+
37+
The YML file has the following structure:
38+
AnalysisType: # rule, scheduled_rule, correlation_rule, or policy
39+
Enabled: # boolean
40+
FileName: # the Python file name
41+
RuleID: # or PolicyId
42+
LogTypes:
43+
Tags:
44+
Tests:
45+
ScheduledQueries: # only applicable to scheduled rules
46+
Suppressions: # only applicable to policies
47+
CreateAlert: # not applicable to policies
48+
Severity:
49+
Description:
50+
DedupPeriodMinutes:
51+
Threshold:
52+
DisplayName:
53+
OutputIds:
54+
Reference:
55+
Runbook:
56+
SummaryAttributes:
57+
58+
The Python file can contain the following functions:
59+
- `rule(event: Dict[str, Any]) -> bool`: The main detection logic that determines if an alert is sent. Returns `True` if the event matches the rule criteria, `False` otherwise. REQUIRED FOR ALL DETECTIONS.
60+
- `title(event: Dict[str, Any]) -> str`: Returns a human-readable alert title with event interpolation sent to alert destinations. THIS IS ALSO THE DEFAULT DEDUP(). Do not make it too unique, otherwise too many alerts will be sent. REQUIRED FOR ALL DETECTIONS BUT NOT FOR SIGNALS.
61+
- `dedup(event: Dict[str, Any]) -> str`: A deduplication key for the alert. OPTIONAL. Only use if specifically instructed by user.
62+
- `alert_context(event: Dict[str, Any]) -> Dict[str, Any]`: Quick context included in the alert that describes the important parts of the log for analysts. OPTIONAL.
63+
64+
ONLY USE THE FOLLOWING FUNCTIONS IF DYNAMIC SELECTION IS REQUESTED BY THE USER EXPLICITLY. OTHERWISE, use the related YAML field.
65+
- `severity(event: Dict[str, Any]) -> str`: The risk level of the alert (INFO, LOW, MEDIUM, HIGH, CRITICAL based on the `Severity` enum). Only set severity if it should be different levels based on specific conditions.
66+
- `destinations(event: Dict[str, Any]) -> List[str]`: Returns a list of destinations to send the alert to. Only add this when the user specifies.
67+
- `runbook(event: Dict[str, Any]) -> str`: The steps to triage the alert and recommend next steps.
68+
- `reference(event: Dict[str, Any]) -> str`: A reference to additional information about the alert, typically a URL to documentation.
69+
70+
If a user asks to create a Signal, then:
71+
1. Set `CreateAlert` to false
72+
2. Set `Severity` to INFO
73+
3. ONLY include the rule method
74+
4. Ignore alert-related metadata like deduplication

.github/workflows/validate.yml

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
on:
2-
pull_request:
3-
types:
4-
- closed
2+
pull_request_review:
3+
types: [submitted]
54

65
permissions:
76
contents: read
87

98
jobs:
109
validate:
11-
if: github.event.pull_request.merged == true
10+
if: github.event.review.state == 'approved' && github.event.pull_request.head.repo.fork == false
1211
name: Validate
1312
runs-on: ubuntu-latest
1413
env:

queries/snowflake_queries/snowflake_0108977_configuration_drift_query.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Query: |
1717
start_time as p_event_time,
1818
end_time
1919
FROM snowflake.account_usage.query_history
20-
WHERE execution_status = 'SUCCESS'
20+
WHERE ((execution_status = 'SUCCESS'
2121
AND query_type NOT in ('SELECT')
2222
AND user_name NOT in ('PANTHER_ADMIN', 'PANTHERACCOUNTADMIN')
2323
AND (query_text ILIKE '%create role%'
@@ -41,11 +41,11 @@ Query: |
4141
OR query_text ILIKE '%drop_network_policy%'
4242
OR query_text ILIKE '%copy%'
4343
)
44-
OR (
44+
) OR (
4545
query_text ilike '%grant%accountadmin%to%'
4646
AND query_type = 'GRANT'
4747
AND execution_status = 'SUCCESS'
48-
)
48+
))
4949
AND p_occurs_since('1 day')
5050
ORDER BY end_time desc
5151
LIMIT 100;

queries/snowflake_queries/snowflake_0108977_configuration_drift_threat_hunting.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Query: |
1414
start_time as p_event_time,
1515
end_time
1616
FROM snowflake.account_usage.query_history
17-
WHERE execution_status = 'SUCCESS'
17+
WHERE ((execution_status = 'SUCCESS'
1818
AND query_type NOT in ('SELECT')
1919
AND user_name NOT in ('PANTHER_ADMIN', 'PANTHERACCOUNTADMIN')
2020
AND (query_text ILIKE '%create role%'
@@ -38,10 +38,10 @@ Query: |
3838
OR query_text ILIKE '%drop_network_policy%'
3939
OR query_text ILIKE '%copy%'
4040
)
41-
OR (
41+
) OR (
4242
query_text ilike '%grant%accountadmin%to%'
4343
AND query_type = 'GRANT'
4444
AND execution_status = 'SUCCESS'
45-
)
45+
))
4646
ORDER BY end_time desc
4747
LIMIT 100;

rules/aws_cloudtrail_rules/aws_resource_made_public.py

+84-45
Original file line numberDiff line numberDiff line change
@@ -5,67 +5,106 @@
55
from policyuniverse.policy import Policy
66

77

8-
# Check that the IAM policy allows resource accessibility via the Internet
9-
def policy_is_internet_accessible(json_policy):
10-
if json_policy is None:
8+
# Check if a policy (string or JSON) allows resource accessibility via the Internet
9+
# pylint: disable=too-complex
10+
def policy_is_internet_accessible(policy):
11+
"""
12+
Check if a policy (string or JSON) allows resource accessibility via the Internet.
13+
14+
Args:
15+
policy: A policy object that can be either a string or a JSON object
16+
17+
Returns:
18+
bool: True if the policy allows internet access, False otherwise
19+
"""
20+
# Handle empty policies (None, empty strings, empty dicts, etc.)
21+
if not policy:
1122
return False
12-
return Policy(json_policy).is_internet_accessible()
1323

24+
# Handle string policies by converting to JSON
25+
if isinstance(policy, str):
26+
try:
27+
policy = json.loads(policy)
28+
except json.JSONDecodeError:
29+
return False
1430

15-
# Normally this check helps avoid overly complex functions that are doing too many things,
16-
# but in this case we explicitly want to handle 10 different cases in 10 different ways.
17-
# Any solution that avoids too many return statements only increases the complexity of this rule.
18-
# pylint: disable=too-many-return-statements, too-complex
19-
def rule(event):
20-
if not aws_cloudtrail_success(event):
21-
return False
31+
# Check if the policy has a wildcard principal but also has organization ID restrictions
32+
# which should not be considered internet accessible
33+
policy_obj = Policy(policy)
2234

23-
parameters = event.get("requestParameters", {})
24-
# Ignore events that are missing request params
25-
if not parameters:
35+
# If policyuniverse thinks it's not internet accessible, trust that
36+
if not policy_obj.is_internet_accessible():
2637
return False
2738

28-
policy = ""
29-
30-
# S3
31-
if event["eventName"] == "PutBucketPolicy":
32-
return policy_is_internet_accessible(parameters.get("bucketPolicy"))
39+
# For policies with multiple statements, we need to check each statement individually
40+
# If ANY statement is truly internet accessible, the policy is internet accessible
41+
has_internet_accessible_statement = False
3342

34-
# ECR
35-
if event["eventName"] == "SetRepositoryPolicy":
36-
policy = parameters.get("policyText", {})
43+
for statement in policy_obj.statements:
44+
if statement.effect != "Allow" or "*" not in statement.principals:
45+
continue
3746

38-
# Elasticsearch
39-
if event["eventName"] in ["CreateElasticsearchDomain", "UpdateElasticsearchDomainConfig"]:
40-
policy = parameters.get("accessPolicies", {})
47+
# Check if there are organization ID conditions which restrict access
48+
has_org_condition = False
49+
for condition in statement.condition_entries:
50+
if condition.category == "organization":
51+
has_org_condition = True
52+
break
4153

42-
# KMS
43-
if event["eventName"] in ["CreateKey", "PutKeyPolicy"]:
44-
policy = parameters.get("policy", {})
54+
# If this statement has a wildcard principal but no organization ID restrictions,
55+
# it's truly internet accessible
56+
if not has_org_condition:
57+
has_internet_accessible_statement = True
58+
break
4559

46-
# S3 Glacier
47-
if event["eventName"] == "SetVaultAccessPolicy":
48-
policy = deep_get(parameters, "policy", "policy", default={})
60+
return has_internet_accessible_statement
4961

50-
# SNS & SQS
51-
if event["eventName"] in ["SetQueueAttributes", "CreateTopic"]:
52-
policy = deep_get(parameters, "attributes", "Policy", default={})
5362

54-
# SNS
55-
if (
56-
event["eventName"] == "SetTopicAttributes"
57-
and parameters.get("attributeName", "") == "Policy"
58-
):
59-
policy = parameters.get("attributeValue", {})
63+
def rule(event):
64+
if not aws_cloudtrail_success(event):
65+
return False
6066

61-
# SecretsManager
62-
if event["eventName"] == "PutResourcePolicy":
63-
policy = parameters.get("resourcePolicy", {})
67+
parameters = event.get("requestParameters", {})
68+
# Ignore events that are missing request params
69+
if not parameters:
70+
return False
6471

65-
if not policy:
72+
event_name = event.get("eventName", "")
73+
74+
# Special case for SNS topic attributes that need additional attribute name check
75+
if event_name == "SetTopicAttributes" and parameters.get("attributeName", "") == "Policy":
76+
policy_value = parameters.get("attributeValue", {})
77+
return policy_is_internet_accessible(policy_value)
78+
79+
# Map of event names to policy locations in parameters
80+
policy_location_map = {
81+
# S3
82+
"PutBucketPolicy": lambda p: p.get("bucketPolicy", {}),
83+
# ECR
84+
"SetRepositoryPolicy": lambda p: p.get("policyText", {}),
85+
# Elasticsearch
86+
"CreateElasticsearchDomain": lambda p: p.get("accessPolicies", {}),
87+
"UpdateElasticsearchDomainConfig": lambda p: p.get("accessPolicies", {}),
88+
# KMS
89+
"CreateKey": lambda p: p.get("policy", {}),
90+
"PutKeyPolicy": lambda p: p.get("policy", {}),
91+
# S3 Glacier
92+
"SetVaultAccessPolicy": lambda p: deep_get(p, "policy", "policy", default={}),
93+
# SNS & SQS
94+
"SetQueueAttributes": lambda p: deep_get(p, "attributes", "Policy", default={}),
95+
"CreateTopic": lambda p: deep_get(p, "attributes", "Policy", default={}),
96+
# SecretsManager
97+
"PutResourcePolicy": lambda p: p.get("resourcePolicy", {}),
98+
}
99+
100+
# Get the policy extraction function for this event name
101+
policy_extractor = policy_location_map.get(event_name)
102+
if not policy_extractor:
66103
return False
67104

68-
return policy_is_internet_accessible(json.loads(policy))
105+
# Extract the policy using the appropriate function
106+
policy = policy_extractor(parameters)
107+
return policy_is_internet_accessible(policy)
69108

70109

71110
def title(event):

0 commit comments

Comments
 (0)