diff --git a/correlation_rules/snowflake_potential_brute_force_success.yml b/correlation_rules/snowflake_potential_brute_force_success.yml new file mode 100644 index 000000000..54c13e45a --- /dev/null +++ b/correlation_rules/snowflake_potential_brute_force_success.yml @@ -0,0 +1,47 @@ +AnalysisType: correlation_rule +RuleID: "Snowflake.PotentialBruteForceSuccess" +DisplayName: "Snowflake Brute Force Login Success" +Enabled: true +Severity: High +Description: Detecting brute force activity and reporting when a user has incorrectly logged in multiple times and then had a successful login. +Detection: + - Sequence: + - ID: Multiple Failed Logins + RuleID: Snowflake.Stream.BruteForceByIp + MinMatchCount: 5 + - ID: Successful Login + RuleID: Snowflake.Stream.LoginSuccess + Transitions: + - ID: Multiple Failed Logins FOLLOWED BY Successful Login + From: Multiple Failed Logins + To: Successful Login + WithinTimeFrameMinutes: 30 + Match: + - On: CLIENT_IP + Schedule: + RateMinutes: 720 + TimeoutMinutes: 15 + LookbackWindowMinutes: 1440 +Tests: + - Name: Successful Bulk Login + ExpectedResult: true + RuleOutputs: + - ID: Multiple Failed Logins + Matches: + CLIENT_IP: + "1.1.1.1": [0, 2, 3, 6, 9, 10, 11, 15] + - ID: Successful Login + Matches: + CLIENT_IP: + "1.1.1.1": [16] + - Name: Successful Login With Single Failure + ExpectedResult: false + RuleOutputs: + - ID: Multiple Failed Logins + Matches: + CLIENT_IP: + "1.1.1.1": [0] + - ID: Successful Login + Matches: + CLIENT_IP: + "1.1.1.1": [1] diff --git a/global_helpers/panther_snowflake_helpers.py b/global_helpers/panther_snowflake_helpers.py new file mode 100644 index 000000000..a09ec5bd2 --- /dev/null +++ b/global_helpers/panther_snowflake_helpers.py @@ -0,0 +1,11 @@ +""" Global helpers for Snowflake streaming detections. """ + + +def query_history_alert_context(event): + return { + "user": event.get("user_name", ""), + "role": event.get("role_name", ""), + "source": event.get("p_source_label", ""), + # Not all queries are run in a warehouse; e.g.: getting worksheet files + "warehouse": event.get("WAREHOUSE_NAME", ""), + } diff --git a/global_helpers/panther_snowflake_helpers.yml b/global_helpers/panther_snowflake_helpers.yml new file mode 100644 index 000000000..8222120d3 --- /dev/null +++ b/global_helpers/panther_snowflake_helpers.yml @@ -0,0 +1,5 @@ +AnalysisType: global +Filename: panther_snowflake_helpers.py +GlobalID: "panther_snowflake_helpers" +Description: > + Global helpers for Snowflake streaming detections diff --git a/packs/snowflake_streaming.yml b/packs/snowflake_streaming.yml new file mode 100644 index 000000000..a7f5439d3 --- /dev/null +++ b/packs/snowflake_streaming.yml @@ -0,0 +1,23 @@ +AnalysisType: pack +PackID: PantherManaged.SnowflakeStreaming +DisplayName: "Panther Snowflake Real-Time Rules" +Description: Group of all streaming (real-time) Snowflake detections +PackDefinition: + IDs: + # Correlation Rules + - Snowflake.PotentialBruteForceSuccess + # Helpers + - panther_snowflake_helpers + # Rules + - Snowflake.Stream.AccountAdminGranted + - Snowflake.Stream.BruteForceByIp + - Snowflake.Stream.BruteForceByUsername + - Snowflake.Stream.ExternalShares + - Snowflake.Stream.FileDownloaded + - Snowflake.Stream.LoginSuccess + - Snowflake.Stream.LoginWithoutMFA + - Snowflake.Stream.PublicRoleGrant + - Snowflake.Stream.TableCopiedIntoStage + - Snowflake.Stream.TempStageCreated + - Snowflake.Stream.UserCreated + - Snowflake.Stream.UserEnabled \ No newline at end of file diff --git a/rules/snowflake_rules/snowflake_stream_account_admin_assigned.py b/rules/snowflake_rules/snowflake_stream_account_admin_assigned.py new file mode 100644 index 000000000..911d47ca1 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_account_admin_assigned.py @@ -0,0 +1,12 @@ +def rule(event): + if event.get("DELETED_ON"): + return False + return "admin" in event.get("GRANTEE_NAME", "").lower() + + +def title(event): + source_name = event.get("p_source_label", "") + target = event.get("GRANTED_TO", "") + actor = event.get("GRANTED_BY", "") + role = event.get("GRANTEE_NAME", "") + return f"{source_name}: {actor} granted role {role} to {target}" diff --git a/rules/snowflake_rules/snowflake_stream_account_admin_assigned.yml b/rules/snowflake_rules/snowflake_stream_account_admin_assigned.yml new file mode 100644 index 000000000..c8d08df3b --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_account_admin_assigned.yml @@ -0,0 +1,41 @@ +AnalysisType: rule +Filename: snowflake_stream_account_admin_assigned.py +RuleID: "Snowflake.Stream.AccountAdminGranted" +DisplayName: Snowflake Account Admin Granted +Enabled: true +LogTypes: + - Snowflake.GrantsToUsers +Severity: Medium +Reports: + MITRE ATT&CK: + - TA0004:T1078 +Description: Detect when account admin is granted. +Tags: + - Snowflake + - '[MITRE] Privilege Escalation' + - '[MITRE] Valid Accounts' +Tests: + - Name: Admin Role Assigned + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-08 11:24:50.682000000", + "p_log_type": "Snowflake.GrantsToUsers", + "p_source_label": "Snowflake Prod", + "CREATED_ON": "2024-10-08 11:24:50.682000000", + "GRANTED_BY": "SNOWFLAKE", + "GRANTED_TO": "APPLICATION_ROLE", + "GRANTEE_NAME": "TRUST_CENTER_ADMIN" + } + - Name: Non-Admin Role Assigned + ExpectedResult: false + Log: + { + "p_event_time": "2024-10-08 11:24:50.682000000", + "p_log_type": "Snowflake.GrantsToUsers", + "p_source_label": "Snowflake Prod", + "CREATED_ON": "2024-10-08 11:24:50.682000000", + "GRANTED_BY": "SNOWFLAKE", + "GRANTED_TO": "APPLICATION_ROLE", + "GRANTEE_NAME": "TRUST_CENTER_VIEWER" + } diff --git a/rules/snowflake_rules/snowflake_stream_brute_force_by_ip.py b/rules/snowflake_rules/snowflake_stream_brute_force_by_ip.py new file mode 100644 index 000000000..75891247c --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_brute_force_by_ip.py @@ -0,0 +1,18 @@ +def rule(event): + # Return true for any login attempt; Let Panther's dedup and threshold handle the brute force + # detection. + return event.get("EVENT_TYPE") == "LOGIN" and event.get("IS_SUCCESS") == "NO" + + +def title(event): + return ( + "Login attempts from IP " + f"{event.get('CLIENT_IP', '')} " + "have exceeded the failed logins threshold" + ) + + +def dedup(event): + return event.get("CLIENT_IP", "") + event.get( + "REPORTED_CLIENT_TYPE", "" + ) diff --git a/rules/snowflake_rules/snowflake_stream_brute_force_by_ip.yml b/rules/snowflake_rules/snowflake_stream_brute_force_by_ip.yml new file mode 100644 index 000000000..bc9557fb0 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_brute_force_by_ip.yml @@ -0,0 +1,56 @@ +AnalysisType: rule +Filename: snowflake_stream_brute_force_by_ip.py +RuleID: "Snowflake.Stream.BruteForceByIp" +DisplayName: Snowflake Brute Force Attacks by IP +Enabled: true +LogTypes: + - Snowflake.LoginHistory +Severity: Medium +Reports: + MITRE ATT&CK: + - TA0006:T1110 +Description: Detect brute force attacks by monitorign failed logins from the same + IP address +DedupPeriodMinutes: 60 +Threshold: 5 +Tags: + - Snowflake + - '[MITRE] Credential Access' + - '[MITRE] Brute Force' +Tests: + - Name: Successful Login + ExpectedResult: false + Log: + { + "p_event_time": "2024-10-08 14:38:46.061000000", + "p_log_type": "Snowflake.LoginHistory", + "p_source_label": "Snowflake Prod", + "CLIENT_IP": "1.1.1.1", + "EVENT_ID": "393754014361778", + "EVENT_TIMESTAMP": "2024-10-08 14:38:46.061000000", + "EVENT_TYPE": "LOGIN", + "FIRST_AUTHENTICATION_FACTOR": "PASSWORD", + "IS_SUCCESS": "YES", + "RELATED_EVENT_ID": "0", + "REPORTED_CLIENT_TYPE": "OTHER", + "REPORTED_CLIENT_VERSION": "1.11.1", + "USER_NAME": "ckent@dailyplanet.org" + } + - Name: Unsuccessful Login + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-08 14:38:46.061000000", + "p_log_type": "Snowflake.LoginHistory", + "p_source_label": "Snowflake Prod", + "CLIENT_IP": "1.2.3.4", + "EVENT_ID": "393754014361778", + "EVENT_TIMESTAMP": "2024-10-08 14:38:46.061000000", + "EVENT_TYPE": "LOGIN", + "FIRST_AUTHENTICATION_FACTOR": "PASSWORD", + "IS_SUCCESS": "NO", + "RELATED_EVENT_ID": "0", + "REPORTED_CLIENT_TYPE": "OTHER", + "REPORTED_CLIENT_VERSION": "1.11.1", + "USER_NAME": "luthor@lexcorp.com" + } diff --git a/rules/snowflake_rules/snowflake_stream_brute_force_by_username.py b/rules/snowflake_rules/snowflake_stream_brute_force_by_username.py new file mode 100644 index 000000000..b9cc76dce --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_brute_force_by_username.py @@ -0,0 +1,16 @@ +def rule(event): + # Return true for any login attempt; Let Panther's dedup and threshold handle the brute force + # detection. + return event.get("EVENT_TYPE") == "LOGIN" and event.get("IS_SUCCESS") == "NO" + + +def title(event): + return ( + f"User {event.get('USER_NAME', '')} has exceeded the failed logins threshold" + ) + + +def dedup(event): + return event.get("USER_NAME", "") + event.get( + "REPORTED_CLIENT_TYPE", "" + ) diff --git a/rules/snowflake_rules/snowflake_stream_brute_force_by_username.yml b/rules/snowflake_rules/snowflake_stream_brute_force_by_username.yml new file mode 100644 index 000000000..80fe931bb --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_brute_force_by_username.yml @@ -0,0 +1,56 @@ +AnalysisType: rule +Filename: snowflake_stream_brute_force_by_username.py +RuleID: "Snowflake.Stream.BruteForceByUsername" +DisplayName: Snowflake Brute Force Attacks by User +Enabled: true +LogTypes: + - Snowflake.LoginHistory +Severity: Medium +Reports: + MITRE ATT&CK: + - TA0006:T1110 +Description: Detect brute force attacks by monitorign failed logins from the same + IP address +DedupPeriodMinutes: 60 +Threshold: 5 +Tags: + - Snowflake + - '[MITRE] Credential Access' + - '[MITRE] Brute Force' +Tests: + - Name: Successful Login + ExpectedResult: false + Log: + { + "p_event_time": "2024-10-08 14:38:46.061000000", + "p_log_type": "Snowflake.LoginHistory", + "p_source_label": "Snowflake Prod", + "CLIENT_IP": "1.1.1.1", + "EVENT_ID": "393754014361778", + "EVENT_TIMESTAMP": "2024-10-08 14:38:46.061000000", + "EVENT_TYPE": "LOGIN", + "FIRST_AUTHENTICATION_FACTOR": "PASSWORD", + "IS_SUCCESS": "YES", + "RELATED_EVENT_ID": "0", + "REPORTED_CLIENT_TYPE": "OTHER", + "REPORTED_CLIENT_VERSION": "1.11.1", + "USER_NAME": "ckent@dailyplanet.org" + } + - Name: Unsuccessful Login + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-08 14:38:46.061000000", + "p_log_type": "Snowflake.LoginHistory", + "p_source_label": "Snowflake Prod", + "CLIENT_IP": "1.2.3.4", + "EVENT_ID": "393754014361778", + "EVENT_TIMESTAMP": "2024-10-08 14:38:46.061000000", + "EVENT_TYPE": "LOGIN", + "FIRST_AUTHENTICATION_FACTOR": "PASSWORD", + "IS_SUCCESS": "NO", + "RELATED_EVENT_ID": "0", + "REPORTED_CLIENT_TYPE": "OTHER", + "REPORTED_CLIENT_VERSION": "1.11.1", + "USER_NAME": "luthor@lexcorp.com" + } diff --git a/rules/snowflake_rules/snowflake_stream_external_shares.py b/rules/snowflake_rules/snowflake_stream_external_shares.py new file mode 100644 index 000000000..b57b6e0cf --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_external_shares.py @@ -0,0 +1,36 @@ +# CONFIGURATION REQUIRED +# Be sure to add code to exclude any transfers from acounts designed to host data shares. Either +# add those account names to the set below, or add a rule filter to exclude events with those +# account names. +DATA_SHARE_HOSTING_ACCOUNTS = { + # Add account names here +} + + +def rule(event): + return all( + [ + event.get("ACCOUNT_NAME") not in get_data_share_hosting_accounts(), + event.get("SOURCE_CLOUD"), + event.get("TARGET_CLOUD"), + event.get("BYTES_TRANSFERRED", 0) > 0, + ] + ) + + +def title(event): + return ( + f"{event.get('ORGANIZATION_NAME', '')}: " + "A data export has been initiated from source cloud " + f"{event.get('SOURCE_CLOUD', '')} " + f"in source region {event.get('SOURCE_REGION', '')} " + f"to target cloud {event.get('TARGET_CLOUD', '')} " + f"in target region {event.get('TARGET_REGION', '')} " + f"with transfer type {event.get('TRANSFER_TYPE', '')} " + f"for {event.get('BYTES_TRANSFERRED', '')} bytes" + ) + + +def get_data_share_hosting_accounts(): + """Getter function. Used so we can mock during unit tests.""" + return DATA_SHARE_HOSTING_ACCOUNTS diff --git a/rules/snowflake_rules/snowflake_stream_external_shares.yml b/rules/snowflake_rules/snowflake_stream_external_shares.yml new file mode 100644 index 000000000..bbb689d8f --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_external_shares.yml @@ -0,0 +1,54 @@ +AnalysisType: rule +Filename: snowflake_stream_external_shares.py +RuleID: Snowflake.Stream.ExternalShares +DisplayName: Snowflake External Data Share +Enabled: true +LogTypes: + - Snowflake.DataTransferHistory +Severity: Medium +Reports: + MITRE ATT&CK: + - TA0010:T1537 +Description: Detect when an external share has been initiated from one source cloud + to another target cloud. +Runbook: Determine if this occurred as a result of a valid business request. +Tags: + - Configuration Required + - Snowflake + - '[MITRE] Exfiltration' + - '[MITRE] Transfer Data to Cloud Account' +Tests: + - Name: Allowed Share + ExpectedResult: false + Mocks: + - objectName: get_data_share_hosting_accounts + returnValue: '{DP_EUROPE}, {DP_ASIA}, {DP_AMERICA}' + Log: + { + "ORGANIZATION_NAME": "DAILY_PLANET", + "ACCOUNT_NAME": "DP_EUROPE", + "REGION": "US-EAST-2", + "SOURCE_CLOUD": "AWS", + "SOURCE_REGION": "US-EAST-2", + "TARGET_CLOUD": "AWS", + "TARGET_REGION": "EU-WEST-1", + "BYTES_TRANSFERRED": 61235879, + "TRANSFER_TYPE": "COPY" + } + - Name: Disallowed Share + ExpectedResult: true + Mocks: + - objectName: get_data_share_hosting_accounts + returnValue: '{DP_EUROPE}, {DP_ASIA}, {DP_AMERICA}' + Log: + { + "ORGANIZATION_NAME": "LEXCORP", + "ACCOUNT_NAME": "LEX_SECRET_ACCOUNT", + "REGION": "US-EAST-2", + "SOURCE_CLOUD": "AWS", + "SOURCE_REGION": "US-EAST-2", + "TARGET_CLOUD": "AWS", + "TARGET_REGION": "EU-WEST-1", + "BYTES_TRANSFERRED": 61235879, + "TRANSFER_TYPE": "COPY" + } diff --git a/rules/snowflake_rules/snowflake_stream_file_downloaded.py b/rules/snowflake_rules/snowflake_stream_file_downloaded.py new file mode 100644 index 000000000..620011a9b --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_file_downloaded.py @@ -0,0 +1,39 @@ +import re + +from panther_snowflake_helpers import query_history_alert_context + +PATH_EXPR = re.compile(r"'file:\/\/([\/\w\.]+)'", flags=re.I) +STAGE_EXPR = re.compile(r"GET\s+'(@[\w\.\/\@\%]+)'", flags=re.I) + +PATH = "" +STAGE = "" + + +def rule(event): + # pylint: disable=global-statement + # Check these conditions first to avoid running an expensive regex on every log + if not all( + ( + event.get("QUERY_TYPE") == "GET_FILES", + event.get("EXECUTION_STATUS") == "SUCCESS", + # Avoid alerting for fetching worksheets: + event.get("QUERY_TEXT") != "GET '@~/worksheet_data/metadata' 'file:///'", + ) + ): + return False + + global PATH + PATH = PATH_EXPR.search(event.get("QUERY_TEXT", "")) + + return PATH is not None + + +def alert_context(event): + # pylint: disable=global-statement + global PATH + global STAGE + STAGE = STAGE_EXPR.match(event.get("QUERY_TEXT", "")) + return query_history_alert_context(event) | { + "path": PATH.group(1), + "stage": None if not STAGE else STAGE.group(1), + } diff --git a/rules/snowflake_rules/snowflake_stream_file_downloaded.yml b/rules/snowflake_rules/snowflake_stream_file_downloaded.yml new file mode 100644 index 000000000..c85e6e8e0 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_file_downloaded.yml @@ -0,0 +1,46 @@ +AnalysisType: rule +Filename: snowflake_stream_file_downloaded.py +RuleID: Snowflake.Stream.FileDownloaded +DisplayName: Snowflake File Downloaded +Enabled: true +LogTypes: + - Snowflake.QueryHistory +Severity: Info +CreateAlert: false +Reports: + MITRE ATT&CK: + - TA0010:T1041 # Exfiltration Over C2 Channel +Description: A file was downloaded from a stage. +Reference: + https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion/ +Tags: + - Snowflake + - '[MITRE] Exfiltration' + - '[MITRE] Exfiltration Over C2 Channel' +Tests: + - Name: Worksheet File Downloaded + ExpectedResult: false + Log: + { + "p_event_time": "2024-10-09 19:38:06.158000000", + "p_log_type": "Snowflake.QueryHistory", + "p_source_label": "SF-Ben", + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "GET '@~/worksheet_data/metadata' 'file:///'", + "QUERY_TYPE": "GET_FILES", + "ROLE_NAME": "PUBLIC", + "USER_NAME": "CLARK_KENT" + } + - Name: Other File Downloaded + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-09 19:38:06.158000000", + "p_log_type": "Snowflake.QueryHistory", + "p_source_label": "SF-Ben", + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "GET '@%/secret_files' 'file:///research/superman_identity'", + "QUERY_TYPE": "GET_FILES", + "ROLE_NAME": "PUBLIC", + "USER_NAME": "LEX_LUTHOR" + } diff --git a/rules/snowflake_rules/snowflake_stream_login_success.py b/rules/snowflake_rules/snowflake_stream_login_success.py new file mode 100644 index 000000000..8f9fcfac2 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_login_success.py @@ -0,0 +1,2 @@ +def rule(event): + return all((event.get("EVENT_TYPE") == "LOGIN", event.get("IS_SUCCESS") == "YES")) diff --git a/rules/snowflake_rules/snowflake_stream_login_success.yml b/rules/snowflake_rules/snowflake_stream_login_success.yml new file mode 100644 index 000000000..2eaa305f7 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_login_success.yml @@ -0,0 +1,49 @@ +AnalysisType: rule +Filename: snowflake_stream_login_success.py +RuleID: Snowflake.Stream.LoginSuccess +DisplayName: Snowflake Successful Login +Enabled: true +LogTypes: + - Snowflake.LoginHistory +Severity: Info +CreateAlert: false +Description: Track successful login signals for correlation. +Tags: + - Snowflake +Tests: + - Name: Successful Login + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-08 14:38:46.061000000", + "p_log_type": "Snowflake.LoginHistory", + "p_source_label": "Snowflake Prod", + "CLIENT_IP": "1.1.1.1", + "EVENT_ID": "393754014361778", + "EVENT_TIMESTAMP": "2024-10-08 14:38:46.061000000", + "EVENT_TYPE": "LOGIN", + "FIRST_AUTHENTICATION_FACTOR": "PASSWORD", + "IS_SUCCESS": "YES", + "RELATED_EVENT_ID": "0", + "REPORTED_CLIENT_TYPE": "OTHER", + "REPORTED_CLIENT_VERSION": "1.11.1", + "USER_NAME": "ckent@dailyplanet.org" + } + - Name: Unsuccessful Login + ExpectedResult: false + Log: + { + "p_event_time": "2024-10-08 14:38:46.061000000", + "p_log_type": "Snowflake.LoginHistory", + "p_source_label": "Snowflake Prod", + "CLIENT_IP": "1.2.3.4", + "EVENT_ID": "393754014361778", + "EVENT_TIMESTAMP": "2024-10-08 14:38:46.061000000", + "EVENT_TYPE": "LOGIN", + "FIRST_AUTHENTICATION_FACTOR": "PASSWORD", + "IS_SUCCESS": "NO", + "RELATED_EVENT_ID": "0", + "REPORTED_CLIENT_TYPE": "OTHER", + "REPORTED_CLIENT_VERSION": "1.11.1", + "USER_NAME": "luthor@lexcorp.com" + } diff --git a/rules/snowflake_rules/snowflake_stream_login_without_mfa.py b/rules/snowflake_rules/snowflake_stream_login_without_mfa.py new file mode 100644 index 000000000..551f841c4 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_login_without_mfa.py @@ -0,0 +1,19 @@ +MFA_EXCEPTIONS = {"PANTHER_READONLY", "PANTHER_ADMIN", "PANTHERACCOUNTADMIN"} + + +def rule(event): + return all( + ( + event.get("EVENT_TYPE") == "LOGIN", + event.get("IS_SUCCESS") == "YES", + event.get("FIRST_AUTHENTICATION_FACTOR") == "PASSWORD", + not event.get("SECOND_AUTHENTICATION_FACTOR"), + event.get("USER_NAME") not in MFA_EXCEPTIONS, + ) + ) + + +def title(event): + source = event.get("p_source_label", "") + user = event.get("USER_NAME", "") + return f"{source}: User {user} logged in without MFA" diff --git a/rules/snowflake_rules/snowflake_stream_login_without_mfa.yml b/rules/snowflake_rules/snowflake_stream_login_without_mfa.yml new file mode 100644 index 000000000..a18dc7f9c --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_login_without_mfa.yml @@ -0,0 +1,54 @@ +AnalysisType: rule +Filename: snowflake_stream_login_without_mfa.py +RuleID: Snowflake.Stream.LoginWithoutMFA +DisplayName: Snowflake Login Without MFA +Enabled: false +LogTypes: + - Snowflake.LoginHistory +Severity: Medium +Reports: + MITRE ATT&CK: + - TA0005:T1556 +Description: Detect Snowflake logins without multifactor authentication +Tags: + - Snowflake + - '[MITRE] Defense Evasion' + - '[MITRE] Modify Authentication Process' +Tests: + - Name: Login With MFA + ExpectedResult: false + Log: + { + "p_event_time": "2024-10-08 14:38:46.061000000", + "p_log_type": "Snowflake.LoginHistory", + "p_source_label": "Snowflake Prod", + "CLIENT_IP": "1.1.1.1", + "EVENT_ID": "393754014361778", + "EVENT_TIMESTAMP": "2024-10-08 14:38:46.061000000", + "EVENT_TYPE": "LOGIN", + "FIRST_AUTHENTICATION_FACTOR": "PASSWORD", + "IS_SUCCESS": "YES", + "RELATED_EVENT_ID": "0", + "REPORTED_CLIENT_TYPE": "OTHER", + "REPORTED_CLIENT_VERSION": "1.11.1", + "SECOND_AUTHENTICATION_FACTOR": "OTP", + "USER_NAME": "ckent@dailyplanet.org" + } + - Name: Login Without MFA + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-08 14:38:46.061000000", + "p_log_type": "Snowflake.LoginHistory", + "p_source_label": "Snowflake Prod", + "CLIENT_IP": "1.2.3.4", + "EVENT_ID": "393754014361778", + "EVENT_TIMESTAMP": "2024-10-08 14:38:46.061000000", + "EVENT_TYPE": "LOGIN", + "FIRST_AUTHENTICATION_FACTOR": "PASSWORD", + "IS_SUCCESS": "YES", + "RELATED_EVENT_ID": "0", + "REPORTED_CLIENT_TYPE": "OTHER", + "REPORTED_CLIENT_VERSION": "1.11.1", + "USER_NAME": "luthor@lexcorp.com" + } diff --git a/rules/snowflake_rules/snowflake_stream_public_role_grant.py b/rules/snowflake_rules/snowflake_stream_public_role_grant.py new file mode 100644 index 000000000..ed29088ac --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_public_role_grant.py @@ -0,0 +1,9 @@ +def rule(event): + return event.get("GRANTEE_NAME").lower() == "public" + + +def title(event): + return ( + f"{event.get('p_source_label', '')}: " + f"{event.get('GRANTED_BY', '')} made changes to the PUBLIC role" + ) diff --git a/rules/snowflake_rules/snowflake_stream_public_role_grant.yml b/rules/snowflake_rules/snowflake_stream_public_role_grant.yml new file mode 100644 index 000000000..7f24d306b --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_public_role_grant.yml @@ -0,0 +1,60 @@ +AnalysisType: rule +Filename: snowflake_stream_public_role_grant.py +RuleID: Snowflake.Stream.PublicRoleGrant +DisplayName: Snowflake Grant to Public Role +Enabled: true +LogTypes: + - Snowflake.GrantsToRoles +Severity: Medium +Reports: + MITRE ATT&CK: + - TA0004:T1078.001 +Description: Detect additional grants to the public role. +Runbook: Determine if this is a necessary grant for the public role, which should + be kept to the fewest possible. +Tags: + - Snowflake + - '[MITRE] Privilege Escalation' + - '[MITRE] Valid Accounts' + - '[MITRE] Valid Accounts: Default Accounts' +Tests: + - Name: SELECT Granted to Public + ExpectedResult: true + Log: + { + "p_source_label": "DailyPlanet-Snowflake", + "CREATED_ON": "2024-10-10 12:56:35.822 -0700", + "MODIFIED_ON": "2024-10-10 12:56:35.822 -0700", + "PRIVILEGE": "SELECT", + "GRANTED_ON": "TABLE", + "NAME": "MYTABLE", + "TABLE_CATALOG": "TEST_DB", + "TABLE_SCHEMA": "PUBLIC", + "GRANTED_TO": "ROLE", + "GRANTEE_NAME": "PUBLIC", + "GRANT_OPTION": false, + "GRANTED_BY": "ACCOUNTADMIN", + "DELETED_ON": "", + "GRANTED_BY_ROLE_TYPE": "ROLE", + "OBJECT_INSTANCE": "" + } + - Name: Privilege Granted to Non-PUBLIC Role + ExpectedResult: false + Log: + { + "p_source_label": "DailyPlanet-Snowflake", + "CREATED_ON": "2024-10-10 12:56:35.822 -0700", + "MODIFIED_ON": "2024-10-10 12:56:35.822 -0700", + "PRIVILEGE": "SELECT", + "GRANTED_ON": "TABLE", + "NAME": "MYTABLE", + "TABLE_CATALOG": "TEST_DB", + "TABLE_SCHEMA": "PUBLIC", + "GRANTED_TO": "ROLE", + "GRANTEE_NAME": "APP_READONLY", + "GRANT_OPTION": false, + "GRANTED_BY": "ACCOUNTADMIN", + "DELETED_ON": "", + "GRANTED_BY_ROLE_TYPE": "ROLE", + "OBJECT_INSTANCE": "" + } diff --git a/rules/snowflake_rules/snowflake_stream_table_copied_into_stage.py b/rules/snowflake_rules/snowflake_stream_table_copied_into_stage.py new file mode 100644 index 000000000..fdee88592 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_table_copied_into_stage.py @@ -0,0 +1,26 @@ +import re + +STAGE_EXPR = re.compile(r"COPY\s+INTO\s+(?:\$\$|\')?@([\w\.]+)", flags=re.I) +PATH_EXPR = re.compile(r"COPY\s+INTO\s+(?:\$\$|\')?@([\w\./]+)(?:\$\$|\')?\s+FROM", flags=re.I) + +STAGE = "" + + +def rule(event): + # pylint: disable=global-statement + global STAGE + STAGE = STAGE_EXPR.match(event.get("QUERY_TEXT", "")) + return all( + ( + event.get("QUERY_TYPE") == "UNLOAD", + STAGE is not None, + event.get("EXECUTION_STATUS") == "SUCCESS", + ) + ) + + +def alert_context(event): + # pylint: disable=global-statement + global STAGE + path = PATH_EXPR.match(event.get("QUERY_TEXT", "")) + return {"actor": event.get("USER_NAME"), "path": path.group(1), "stage": STAGE.group(1)} diff --git a/rules/snowflake_rules/snowflake_stream_table_copied_into_stage.yml b/rules/snowflake_rules/snowflake_stream_table_copied_into_stage.yml new file mode 100644 index 000000000..665b7dd69 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_table_copied_into_stage.yml @@ -0,0 +1,40 @@ +AnalysisType: rule +Filename: snowflake_stream_table_copied_into_stage.py +RuleID: Snowflake.Stream.TableCopiedIntoStage +DisplayName: Snowflake Table Copied Into Stage +Enabled: true +LogTypes: + - Snowflake.QueryHistory +Severity: Info +CreateAlert: false +Reports: + MITRE ATT&CK: + - TA0010:T1041 # Exfiltration Over C2 Channel +Description: A table was copied into a stage. +Reference: + https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion/ +Tags: + - Snowflake + - '[MITRE] Exfiltration' + - '[MITRE] Exfiltration Over C2 Channel' +Tests: + - Name: Copy from Table into Stage + ExpectedResult: true + Log: + { + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "COPY INTO @mystage/result/data_\nFROM mytable FILE_FORMAT = + (FORMAT_NAME='CSV' COMPRESSION='GZIP');", + "QUERY_TYPE": "UNLOAD", + "USER_NAME": "LEX_LUTHOR" + } + - Name: Copy from Stage into Table + ExpectedResult: false + Log: + { + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "COPY INTO mytable\nFROM @mystage/result/data_ FILE_FORMAT = + (FORMAT_NAME='CSV' COMPRESSION='GZIP');", + "QUERY_TYPE": "UNLOAD", + "USER_NAME": "LEX_LUTHOR" + } diff --git a/rules/snowflake_rules/snowflake_stream_temp_stage_created.py b/rules/snowflake_rules/snowflake_stream_temp_stage_created.py new file mode 100644 index 000000000..aae1ab3f4 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_temp_stage_created.py @@ -0,0 +1,33 @@ +import re + +from panther_snowflake_helpers import query_history_alert_context + +STAGE_EXPR = re.compile( + ( + r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:TEMPORARY\s+|TEMP\s+)STAGE\s+" + r"(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z0-9_\\.]+)" + ), + flags=re.I, +) + +STAGE = "" + + +def rule(event): + # pylint: disable=global-statement + global STAGE + STAGE = STAGE_EXPR.match(event.get("QUERY_TEXT", "")) + + return all( + ( + event.get("QUERY_TYPE") == "CREATE", + event.get("EXECUTION_STATUS") == "SUCCESS", + STAGE is not None, + ) + ) + + +def alert_context(event): + # pylint: disable=global-statement + global STAGE + return query_history_alert_context(event) | {"stage": STAGE.group(1)} diff --git a/rules/snowflake_rules/snowflake_stream_temp_stage_created.yml b/rules/snowflake_rules/snowflake_stream_temp_stage_created.yml new file mode 100644 index 000000000..f9e00e4ab --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_temp_stage_created.yml @@ -0,0 +1,62 @@ +AnalysisType: rule +Filename: snowflake_stream_temp_stage_created.py +RuleID: Snowflake.Stream.TempStageCreated +DisplayName: Snowflake Temporary Stage Created +Enabled: true +LogTypes: + - Snowflake.QueryHistory +Severity: Info +CreateAlert: false +Reports: + MITRE ATT&CK: + - TA0010:T1041 # Exfiltration Over C2 Channel +Description: A temporary stage was created. +Reference: + https://cloud.google.com/blog/topics/threat-intelligence/unc5537-snowflake-data-theft-extortion/ +Tags: + - Snowflake + - '[MITRE] Exfiltration' + - '[MITRE] Exfiltration Over C2 Channel' +Tests: + - Name: Successful Temp Stage Created + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-09 21:06:03.631000000", + "p_log_type": "Snowflake.QueryHistory", + "p_source_id": "132d65cd-d6e4-4981-a209-a1d5902afd59", + "p_source_label": "SF-Ben", + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "CREATE TEMP STAGE my_temp_stage;", + "QUERY_TYPE": "CREATE", + "USER_NAME": "LEX_LUTHOR", + "WAREHOUSE_NAME": "ADMIN_WH" + } + - Name: Successful Temp Stage Created or Replaced + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-09 21:06:03.631000000", + "p_log_type": "Snowflake.QueryHistory", + "p_source_id": "132d65cd-d6e4-4981-a209-a1d5902afd59", + "p_source_label": "SF-Ben", + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "CREATE OR REPLACE TEMP STAGE my_temp_stage;", + "QUERY_TYPE": "CREATE", + "USER_NAME": "LEX_LUTHOR", + "WAREHOUSE_NAME": "ADMIN_WH" + } + - Name: Unsuccessful Temp Stage Created + ExpectedResult: false + Log: + { + "p_event_time": "2024-10-09 21:06:03.631000000", + "p_log_type": "Snowflake.QueryHistory", + "p_source_id": "132d65cd-d6e4-4981-a209-a1d5902afd59", + "p_source_label": "SF-Ben", + "EXECUTION_STATUS": "FAIL", + "QUERY_TEXT": "CREATE TEMP STAGE my_temp_stage;", + "QUERY_TYPE": "CREATE", + "USER_NAME": "LEX_LUTHOR", + "WAREHOUSE_NAME": "ADMIN_WH" + } diff --git a/rules/snowflake_rules/snowflake_stream_user_created.py b/rules/snowflake_rules/snowflake_stream_user_created.py new file mode 100644 index 000000000..2801c596c --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_user_created.py @@ -0,0 +1,33 @@ +import re + +from panther_snowflake_helpers import query_history_alert_context + +CREATE_USER_EXPR = re.compile(r"create user (\w+).*", flags=re.I) + +CREATE_USER = "" + + +def rule(event): + # pylint: disable=global-statement + global CREATE_USER + CREATE_USER = CREATE_USER_EXPR.match(event.get("QUERY_TEXT", "")) + return all( + ( + event.get("EXECUTION_STATUS") == "SUCCESS", + event.get("QUERY_TYPE") == "CREATE_USER", + CREATE_USER is not None, + ) + ) + + +def title(event): + # pylint: disable=global-statement + global CREATE_USER + new_user = CREATE_USER.group(1) + actor = event.get("user_name", "") + source = event.get("p_source_label", "") + return f"{source}: Snowflake user {new_user} created by {actor}" + + +def alert_context(event): + return query_history_alert_context(event) diff --git a/rules/snowflake_rules/snowflake_stream_user_created.yml b/rules/snowflake_rules/snowflake_stream_user_created.yml new file mode 100644 index 000000000..0748e038d --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_user_created.yml @@ -0,0 +1,31 @@ +AnalysisType: rule +Filename: snowflake_stream_user_created.py +RuleID: Snowflake.Stream.UserCreated +DisplayName: Snowflake User Created +Enabled: false +LogTypes: + - Snowflake.QueryHistory +Severity: Info +Reports: + MITRE ATT&CK: + - TA0003:T1136 +Description: Detect new users created in Snowflake. +Tags: + - Snowflake + - '[MITRE] Persistence' + - '[MITRE] Create Account' +Tests: + - Name: User Created + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-09 19:43:05.007000000", + "p_log_type": "Snowflake.QueryHistory", + "BYTES_DELETED": 0, + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "CREATE USER MERCY\nPASSWORD = '\u263a\u263a\u263a\u263a\u263a'\nDEFAULT_ROLE = PUBLIC;", + "QUERY_TYPE": "CREATE_USER", + "ROLE_NAME": "ACCOUNTADMIN", + "USER_NAME": "LEX_LUTHOR", + "WAREHOUSE_NAME": "ADMIN_WH" + } diff --git a/rules/snowflake_rules/snowflake_stream_user_enabled.py b/rules/snowflake_rules/snowflake_stream_user_enabled.py new file mode 100644 index 000000000..8497637b3 --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_user_enabled.py @@ -0,0 +1,35 @@ +import re + +from panther_snowflake_helpers import query_history_alert_context + +USER_ENABLED_EXPR = re.compile(r"alter\s+user\s+(.+?)\s+.*?set\s+disabled\s*=\s*false", flags=re.I) + +USER_ENABLED = "" + + +def rule(event): + # pylint: disable=global-statement + global USER_ENABLED + USER_ENABLED = USER_ENABLED_EXPR.match(event.get("QUERY_TEXT", "")) + + # Exit out early to avoid needless regex + return all( + ( + event.get("QUERY_TYPE") == "ALTER_USER", + event.get("EXECUTION_STATUS") == "SUCCESS", + USER_ENABLED is not None, + ) + ) + + +def title(event): + # pylint: disable=global-statement + global USER_ENABLED + enabled_user = USER_ENABLED.group(1) + actor = event.get("USER_NAME", "") + source = event.get("p_source_label", "") + return f"{source}: Snowflake user {enabled_user} enabled by {actor}" + + +def alert_context(event): + return query_history_alert_context(event) diff --git a/rules/snowflake_rules/snowflake_stream_user_enabled.yml b/rules/snowflake_rules/snowflake_stream_user_enabled.yml new file mode 100644 index 000000000..e28ddb2fa --- /dev/null +++ b/rules/snowflake_rules/snowflake_stream_user_enabled.yml @@ -0,0 +1,47 @@ +AnalysisType: rule +Filename: snowflake_stream_user_enabled.py +RuleID: Snowflake.Stream.UserEnabled +DisplayName: Snowflake User Enabled +Enabled: true +LogTypes: + - Snowflake.QueryHistory +Severity: Info +Reports: + MITRE ATT&CK: + - TA0003:T1136 +Description: Detects users being re-enabled in your environment. +Tags: + - Snowflake + - '[MITRE] Persistence' + - '[MITRE] Create Account' +Tests: + - Name: User Enabled + ExpectedResult: true + Log: + { + "p_event_time": "2024-10-09 21:03:25.750000000", + "p_log_type": "Snowflake.QueryHistory", + "p_row_id": "6283439ab35193e891ac9ea1227b", + "p_source_label": "SF-Ben", + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "ALTER USER CLARK_KENT SET DISABLED=FALSE;", + "QUERY_TYPE": "ALTER_USER", + "ROLE_NAME": "ACCOUNTADMIN", + "USER_NAME": "LEX_LUTHOR", + "WAREHOUSE_NAME": "DATAOPS_WH" + } + - Name: User Disabled + ExpectedResult: false + Log: + { + "p_event_time": "2024-10-09 21:03:25.750000000", + "p_log_type": "Snowflake.QueryHistory", + "p_row_id": "6283439ab35193e891ac9ea1227b", + "p_source_label": "SF-Ben", + "EXECUTION_STATUS": "SUCCESS", + "QUERY_TEXT": "ALTER USER CLARK_KENT SET DISABLED=TRUE;", + "QUERY_TYPE": "ALTER_USER", + "ROLE_NAME": "ACCOUNTADMIN", + "USER_NAME": "PERRY_WHITE", + "WAREHOUSE_NAME": "DATAOPS_WH" + }