diff --git a/.github/workflows/check-packs.yml b/.github/workflows/check-packs.yml index bb6a3139f..623a6b8c7 100644 --- a/.github/workflows/check-packs.yml +++ b/.github/workflows/check-packs.yml @@ -45,7 +45,7 @@ jobs: panther_analysis_tool check-packs || echo "errors=`cat errors.txt`" >> $GITHUB_OUTPUT - name: Comment PR - uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b if: ${{ steps.check-packs.outputs.errors }} with: mode: upsert @@ -57,7 +57,7 @@ jobs: ``` comment-tag: check-packs - name: Delete comment - uses: thollander/actions-comment-pull-request@e2c37e53a7d2227b61585343765f73a9ca57eda9 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b if: ${{ !steps.check-packs.outputs.errors }} with: mode: delete diff --git a/correlation_rules/aws_cloudtrail_stopinstance_followed_by_modifyinstanceattributes.yml b/correlation_rules/aws_cloudtrail_stopinstance_followed_by_modifyinstanceattributes.yml index 8642ad7fd..08fe4c3f7 100644 --- a/correlation_rules/aws_cloudtrail_stopinstance_followed_by_modifyinstanceattributes.yml +++ b/correlation_rules/aws_cloudtrail_stopinstance_followed_by_modifyinstanceattributes.yml @@ -18,11 +18,12 @@ Detection: - ID: StopInstance FOLLOWED BY StartupScriptChange From: StopInstance To: StartupScriptChange + WithinTimeFrameMinutes: 90 Match: - On: p_alert_context.instance_ids - LookbackWindowMinutes: 90 + LookbackWindowMinutes: 2160 Schedule: - RateMinutes: 60 + RateMinutes: 1440 TimeoutMinutes: 5 Tests: - Name: Instance Stopped, Followed By Script Change diff --git a/correlation_rules/aws_console_sign-in_without_okta.yml b/correlation_rules/aws_console_sign-in_without_okta.yml index 3aa81090f..6d9d45f71 100644 --- a/correlation_rules/aws_console_sign-in_without_okta.yml +++ b/correlation_rules/aws_console_sign-in_without_okta.yml @@ -26,7 +26,7 @@ Detection: Schedule: RateMinutes: 1440 TimeoutMinutes: 5 - LookbackWindowMinutes: 1440 + LookbackWindowMinutes: 2160 Tests: - Name: AWS Console Sign-In PRECEDED BY Okta Redirect ExpectedResult: false diff --git a/correlation_rules/aws_privilege_escalation_via_user_compromise.yml b/correlation_rules/aws_privilege_escalation_via_user_compromise.yml index 7893288b4..d0afa8937 100644 --- a/correlation_rules/aws_privilege_escalation_via_user_compromise.yml +++ b/correlation_rules/aws_privilege_escalation_via_user_compromise.yml @@ -16,12 +16,13 @@ Detection: - ID: User Backdoored TO User Accessed ON IP Addr From: User Backdoored To: User Accessed + WithinTimeFrameMinutes: 60 Match: - On: p_alert_context.ip_accessKeyId Schedule: - RateMinutes: 60 + RateMinutes: 1440 TimeoutMinutes: 10 - LookbackWindowMinutes: 90 + LookbackWindowMinutes: 2160 Tests: - Name: Access Key Created and Used from Same IP ExpectedResult: true diff --git a/correlation_rules/aws_sso_access_token_retrieved_by_unauthenticated_ip.yml b/correlation_rules/aws_sso_access_token_retrieved_by_unauthenticated_ip.yml index 18303a56b..5a2b0058b 100644 --- a/correlation_rules/aws_sso_access_token_retrieved_by_unauthenticated_ip.yml +++ b/correlation_rules/aws_sso_access_token_retrieved_by_unauthenticated_ip.yml @@ -25,7 +25,7 @@ Detection: Schedule: RateMinutes: 1440 TimeoutMinutes: 5 - LookbackWindowMinutes: 1440 + LookbackWindowMinutes: 2160 Tests: - Name: AWS SSO Access Token Retrieved by Authenticated IP ExpectedResult: false diff --git a/correlation_rules/aws_user_takeover_via_password_reset.yml b/correlation_rules/aws_user_takeover_via_password_reset.yml index c7a9b8995..05999b27d 100644 --- a/correlation_rules/aws_user_takeover_via_password_reset.yml +++ b/correlation_rules/aws_user_takeover_via_password_reset.yml @@ -16,12 +16,13 @@ Detection: - ID: Password Reset TO Login ON IP Addr From: Password Reset To: Login + WithinTimeFrameMinutes: 60 Match: - On: p_alert_context.ip_and_username Schedule: - RateMinutes: 60 + RateMinutes: 1440 TimeoutMinutes: 10 - LookbackWindowMinutes: 90 + LookbackWindowMinutes: 2160 Tests: - Name: Password Reset, Then Login From Same IP ExpectedResult: true diff --git a/correlation_rules/gcp_cloud_run_service_create_followed_by_set_iam_policy.yml b/correlation_rules/gcp_cloud_run_service_create_followed_by_set_iam_policy.yml index 51abac686..8a6db86d4 100644 --- a/correlation_rules/gcp_cloud_run_service_create_followed_by_set_iam_policy.yml +++ b/correlation_rules/gcp_cloud_run_service_create_followed_by_set_iam_policy.yml @@ -21,11 +21,12 @@ Detection: - ID: ServiceCreated FOLLOWED BY SetIAMPolicy From: ServiceCreated To: SetIAMPolicy + WithinTimeFrameMinutes: 90 Match: - On: p_alert_context.caller_ip - LookbackWindowMinutes: 90 + LookbackWindowMinutes: 2160 Schedule: - RateMinutes: 60 + RateMinutes: 1440 TimeoutMinutes: 5 Tests: - Name: GCP Service Run, Followed By IAM Policy Change From Same IP diff --git a/correlation_rules/github_advanced_security_change_not_followed_by_repo_archived.yml b/correlation_rules/github_advanced_security_change_not_followed_by_repo_archived.yml index 564781d5d..7b9b5e059 100644 --- a/correlation_rules/github_advanced_security_change_not_followed_by_repo_archived.yml +++ b/correlation_rules/github_advanced_security_change_not_followed_by_repo_archived.yml @@ -16,11 +16,12 @@ Detection: - ID: GHASChange NOT FOLLOWED BY RepoArchived From: RepoArchived To: GHASChange + WithinTimeFrameMinutes: 60 Match: - On: p_alert_context.repo - LookbackWindowMinutes: 90 + LookbackWindowMinutes: 2160 Schedule: - RateMinutes: 60 + RateMinutes: 1440 TimeoutMinutes: 10 Tests: - Name: Security Change on Repo, Followed By Same Repo Archived diff --git a/correlation_rules/notion_login_followed_by_account_change.yml b/correlation_rules/notion_login_followed_by_account_change.yml index 3149b8d40..44dcd406a 100644 --- a/correlation_rules/notion_login_followed_by_account_change.yml +++ b/correlation_rules/notion_login_followed_by_account_change.yml @@ -22,7 +22,7 @@ Detection: WithinTimeFrameMinutes: 15 Match: - On: p_alert_context.actor_id - LookbackWindowMinutes: 1440 + LookbackWindowMinutes: 2160 Schedule: RateMinutes: 1440 TimeoutMinutes: 5 diff --git a/correlation_rules/okta_login_without_push.yml b/correlation_rules/okta_login_without_push.yml index ac0418193..738c00948 100644 --- a/correlation_rules/okta_login_without_push.yml +++ b/correlation_rules/okta_login_without_push.yml @@ -21,13 +21,14 @@ Detection: - ID: Okta to Push From: Okta To: Push + WithinTimeFrameMinutes: 60 Match: - From: actor.alternateId To: new.email Schedule: - RateMinutes: 60 + RateMinutes: 1440 TimeoutMinutes: 10 - LookbackWindowMinutes: 90 + LookbackWindowMinutes: 2160 Tests: - Name: Okta Login, Followed By Push Authorized Login ExpectedResult: false diff --git a/correlation_rules/onelogin_successful_login_after_high_risk_failed_login.yml b/correlation_rules/onelogin_successful_login_after_high_risk_failed_login.yml index 34aa2edac..87155aab1 100644 --- a/correlation_rules/onelogin_successful_login_after_high_risk_failed_login.yml +++ b/correlation_rules/onelogin_successful_login_after_high_risk_failed_login.yml @@ -22,7 +22,7 @@ Detection: WithinTimeFrameMinutes: 15 Match: - On: user_name - LookbackWindowMinutes: 1440 + LookbackWindowMinutes: 2160 Schedule: RateMinutes: 1440 TimeoutMinutes: 5 diff --git a/correlation_rules/potential_compromised_okta_credentials.yml b/correlation_rules/potential_compromised_okta_credentials.yml index 15b9e79d9..4b0536831 100644 --- a/correlation_rules/potential_compromised_okta_credentials.yml +++ b/correlation_rules/potential_compromised_okta_credentials.yml @@ -20,13 +20,14 @@ Detection: - ID: Match on user From: Login Without Push Marker To: Push Phishing + WithinTimeFrameMinutes: 60 Match: - From: actor.alternateId To: new.employee.email Schedule: - RateMinutes: 60 + RateMinutes: 1440 TimeoutMinutes: 10 - LookbackWindowMinutes: 90 + LookbackWindowMinutes: 2160 Tests: - Name: Login Without Marker, Followed By Phishing Detection ExpectedResult: true diff --git a/correlation_rules/secret_exposed_and_not_quarantined.yml b/correlation_rules/secret_exposed_and_not_quarantined.yml index d8cd9e6c4..c50790a03 100644 --- a/correlation_rules/secret_exposed_and_not_quarantined.yml +++ b/correlation_rules/secret_exposed_and_not_quarantined.yml @@ -21,10 +21,11 @@ Detection: - ID: SecretFound TO SecretNotQuarantined From: SecretFound To: SecretNotQuarantined + WithinTimeFrameMinutes: 60 Schedule: - RateMinutes: 60 + RateMinutes: 1440 TimeoutMinutes: 10 - LookbackWindowMinutes: 90 + LookbackWindowMinutes: 2160 Tests: - Name: Secret Found and Quarantied ExpectedResult: false diff --git a/correlation_rules/snowflake_data_exfiltration.yml b/correlation_rules/snowflake_data_exfiltration.yml index 69fcfe6b6..dc6fd272f 100644 --- a/correlation_rules/snowflake_data_exfiltration.yml +++ b/correlation_rules/snowflake_data_exfiltration.yml @@ -28,9 +28,9 @@ Detection: Match: - On: stage Schedule: - RateMinutes: 720 + RateMinutes: 1440 TimeoutMinutes: 15 - LookbackWindowMinutes: 1440 + LookbackWindowMinutes: 2160 Tests: - Name: Data Exfiltration ExpectedResult: true 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_aws_helpers.py b/global_helpers/panther_aws_helpers.py index 5986c9cdd..738db8cb3 100644 --- a/global_helpers/panther_aws_helpers.py +++ b/global_helpers/panther_aws_helpers.py @@ -29,7 +29,7 @@ def aws_strip_role_session_id(user_identity_arn): return user_identity_arn -def aws_rule_context(event: dict): +def aws_rule_context(event): return { "eventName": event.get("eventName", ""), "eventSource": event.get("eventSource", ""), @@ -41,7 +41,7 @@ def aws_rule_context(event: dict): } -def aws_guardduty_context(event: dict): +def aws_guardduty_context(event): return { "description": event.get("description", ""), "severity": event.get("severity", ""), diff --git a/global_helpers/panther_box_helpers.py b/global_helpers/panther_box_helpers.py index cf4259c97..eb65e1dd5 100644 --- a/global_helpers/panther_box_helpers.py +++ b/global_helpers/panther_box_helpers.py @@ -139,7 +139,7 @@ def build_jwt_settings(response: dict) -> dict: # 'additional_details' from box logs varies by event_type. # This helper wraps the process of extracting those details. -def box_parse_additional_details(event: dict): +def box_parse_additional_details(event): additional_details = event.get("additional_details", {}) if isinstance(additional_details, (str, bytes)): try: diff --git a/global_helpers/panther_cloudflare_helpers.py b/global_helpers/panther_cloudflare_helpers.py index 3c2547eee..a0926ae8d 100644 --- a/global_helpers/panther_cloudflare_helpers.py +++ b/global_helpers/panther_cloudflare_helpers.py @@ -38,7 +38,7 @@ def map_source_to_name(event: Any) -> str: ) -def cloudflare_fw_alert_context(event: dict = None): +def cloudflare_fw_alert_context(event=None): keep_keys = [ "Action", "ClientIP", @@ -57,7 +57,7 @@ def cloudflare_fw_alert_context(event: dict = None): return context_dict -def cloudflare_http_alert_context(event: dict = None): +def cloudflare_http_alert_context(event=None): keep_keys = [ "BotScore", "BotScoreSrc", diff --git a/global_helpers/panther_crowdstrike_fdr_helpers.py b/global_helpers/panther_crowdstrike_fdr_helpers.py index 15c28f9db..214b1ffa3 100644 --- a/global_helpers/panther_crowdstrike_fdr_helpers.py +++ b/global_helpers/panther_crowdstrike_fdr_helpers.py @@ -1,4 +1,4 @@ -def crowdstrike_detection_alert_context(event: dict): +def crowdstrike_detection_alert_context(event): """Returns common context for Crowdstrike detections""" return { "aid": get_crowdstrike_field(event, "aid", default=""), @@ -13,7 +13,7 @@ def crowdstrike_detection_alert_context(event: dict): } -def crowdstrike_process_alert_context(event: dict): +def crowdstrike_process_alert_context(event): """Returns common process context for Crowdstrike detections""" return { "aid": get_crowdstrike_field(event, "aid", default=""), @@ -28,7 +28,7 @@ def crowdstrike_process_alert_context(event: dict): } -def crowdstrike_network_detection_alert_context(event: dict): +def crowdstrike_network_detection_alert_context(event): """Returns common network context for Crowdstrike detections""" return { "LocalAddressIP4": get_crowdstrike_field(event, "LocalAddressIP4", default=""), diff --git a/global_helpers/panther_duo_helpers.py b/global_helpers/panther_duo_helpers.py index 6eb00ad58..aea974746 100644 --- a/global_helpers/panther_duo_helpers.py +++ b/global_helpers/panther_duo_helpers.py @@ -2,7 +2,7 @@ from json import JSONDecodeError -def deserialize_administrator_log_event_description(event: dict) -> dict: +def deserialize_administrator_log_event_description(event) -> dict: """Intelligently try and decode a field that is usually stringified json into a python dict. This description field seems to take the form of stringified json, So this function diff --git a/global_helpers/panther_lookuptable_helpers.py b/global_helpers/panther_lookuptable_helpers.py index 295db4a2a..1d059ba14 100644 --- a/global_helpers/panther_lookuptable_helpers.py +++ b/global_helpers/panther_lookuptable_helpers.py @@ -27,7 +27,7 @@ def _lookup(self, match_field: str, *keys) -> list or str: def p_matched(self): return self._p_matched - def p_matches(self, event: dict, p_match: str = "") -> dict: + def p_matches(self, event, p_match: str = "") -> dict: """Collect enrichments by searching for a value match in the p_match field Parameters: diff --git a/global_helpers/panther_okta_helpers.py b/global_helpers/panther_okta_helpers.py index b8cf79cbe..ffbfb8af8 100644 --- a/global_helpers/panther_okta_helpers.py +++ b/global_helpers/panther_okta_helpers.py @@ -1,4 +1,4 @@ -def okta_alert_context(event: dict): +def okta_alert_context(event): """Returns common context for automation of Okta alerts""" return { "event_type": event.get("eventtype", ""), 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/aws.yml b/packs/aws.yml index e47135121..abec46f57 100644 --- a/packs/aws.yml +++ b/packs/aws.yml @@ -143,6 +143,7 @@ PackDefinition: - AWS.CMK.KeyRotation - AWS.DynamoDB.TableTTLEnabled - AWS.EC2.Vulnerable.XZ.Image.Launched + - Amazon.EKS.AnonymousAPIAccess - AWS.IAM.Policy.DoesNotGrantAdminAccess - AWS.IAM.Policy.DoesNotGrantNetworkAdminAccess - AWS.IAM.Resource.DoesNotHaveInlinePolicy 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/aws_eks_rules/anonymous_api_access.py b/rules/aws_eks_rules/anonymous_api_access.py new file mode 100644 index 000000000..b85f24fde --- /dev/null +++ b/rules/aws_eks_rules/anonymous_api_access.py @@ -0,0 +1,30 @@ +from panther_aws_helpers import eks_panther_obj_ref + + +def rule(event): + # Check if the username is set to "system:anonymous", which indicates anonymous access + p_eks = eks_panther_obj_ref(event) + if p_eks.get("actor") == "system:anonymous": + return True + return False + + +def title(event): + p_eks = eks_panther_obj_ref(event) + return ( + f"Anonymous API access detected on Kubernetes API server " + f"from [{p_eks.get('sourceIPs')[0]}] to [{p_eks.get('resource')}] " + f"in namespace [{p_eks.get('ns')}] on [{p_eks.get('p_source_label')}]" + ) + + +def dedup(event): + p_eks = eks_panther_obj_ref(event) + return f"anonymous_access_{p_eks.get('p_source_label')}_{p_eks.get('sourceIPs')[0]}" + + +def alert_context(event): + p_eks = eks_panther_obj_ref(event) + mutable_event = event.to_dict() + mutable_event["p_eks"] = p_eks + return dict(mutable_event) diff --git a/rules/aws_eks_rules/anonymous_api_access.yml b/rules/aws_eks_rules/anonymous_api_access.yml new file mode 100644 index 000000000..e8f2548fb --- /dev/null +++ b/rules/aws_eks_rules/anonymous_api_access.yml @@ -0,0 +1,132 @@ +AnalysisType: rule +Filename: anonymous_api_access.py +RuleID: "Amazon.EKS.AnonymousAPIAccess" +DisplayName: "EKS Anonymous API Access Detected" +Enabled: true +LogTypes: + - Amazon.EKS.Audit +Severity: Medium +Reports: + MITRE ATT&CK: + - "TA0001:T1190" # Initial Access: Exploit Public-Facing Application +Description: > + This rule detects anonymous API requests made to the Kubernetes API server. + In production environments, anonymous access should be disabled to prevent + unauthorized access to the API server. +DedupPeriodMinutes: 60 +Reference: + https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests +Runbook: > + Check the EKS cluster configuration and ensure that anonymous access + to the Kubernetes API server is disabled. This can be done by verifying the API + server arguments and authentication webhook configuration. +SummaryAttributes: + - user:username + - p_any_ip_addresses + - p_source_label +Tags: + - EKS + - Security Control + - API + - Initial Access:Exploit Public-Facing Application +Tests: + - Name: Anonymous API Access + ExpectedResult: true + Log: + { + "annotations": { + "authorization.k8s.io/decision": "allow", + "authorization.k8s.io/reason": "RBAC: allowed by ClusterRoleBinding system:public-info-viewer" + }, + "apiVersion": "audit.k8s.io/v1", + "auditID": "abcde12345", + "kind": "Event", + "level": "Request", + "objectRef": { + "apiVersion": "v1", + "name": "test-pod", + "namespace": "default", + "resource": "pods" + }, + "p_any_aws_account_ids": [ + "123412341234" + ], + "p_any_aws_arns": [ + "arn:aws:iam::123412341234:role/DevAdministrator" + ], + "p_any_ip_addresses": [ + "8.8.8.8" + ], + "p_any_usernames": [ + "system:anonymous" + ], + "p_event_time": "2022-11-29 00:09:04.38", + "p_log_type": "Amazon.EKS.Audit", + "p_parse_time": "2022-11-29 00:10:25.067", + "p_row_id": "2e4ab474b0f0f7a4a8fff4f014a9b32a", + "p_source_id": "4c859cd4-9406-469b-9e0e-c2dc1bee24fa", + "p_source_label": "example-cluster-eks-logs", + "requestReceivedTimestamp": "2022-11-29 00:09:04.38", + "requestURI": "/api/v1/namespaces/default/pods/test-pod", + "responseStatus": { + "code": 200 + }, + "sourceIPs": [ + "8.8.8.8" + ], + "stage": "ResponseComplete", + "user": { + "username": "system:anonymous" + }, + "userAgent": "kubectl/v1.25.4" + } + - Name: Non-Anonymous API Access + ExpectedResult: false + Log: + { + "annotations": { + "authorization.k8s.io/decision": "allow", + "authorization.k8s.io/reason": "RBAC: allowed by ClusterRoleBinding system:public-info-viewer" + }, + "apiVersion": "audit.k8s.io/v1", + "auditID": "abcde12345", + "kind": "Event", + "level": "Request", + "objectRef": { + "apiVersion": "v1", + "name": "test-pod", + "namespace": "default", + "resource": "pods" + }, + "p_any_aws_account_ids": [ + "123412341234" + ], + "p_any_aws_arns": [ + "arn:aws:iam::123412341234:role/DevAdministrator" + ], + "p_any_ip_addresses": [ + "8.8.8.8" + ], + "p_any_usernames": [ + "kubernetes-admin" + ], + "p_event_time": "2022-11-29 00:09:04.38", + "p_log_type": "Amazon.EKS.Audit", + "p_parse_time": "2022-11-29 00:10:25.067", + "p_row_id": "2e4ab474b0f0f7a4a8fff4f014a9b32a", + "p_source_id": "4c859cd4-9406-469b-9e0e-c2dc1bee24fa", + "p_source_label": "example-cluster-eks-logs", + "requestReceivedTimestamp": "2022-11-29 00:09:04.38", + "requestURI": "/api/v1/namespaces/default/pods/test-pod", + "responseStatus": { + "code": 200 + }, + "sourceIPs": [ + "8.8.8.8" + ], + "stage": "ResponseComplete", + "user": { + "username": "kubernetes-admin" + }, + "userAgent": "kubectl/v1.25.4" + } diff --git a/rules/crowdstrike_rules/event_stream_rules/crowdstrike_ephemeral_user_account.yml b/rules/crowdstrike_rules/event_stream_rules/crowdstrike_ephemeral_user_account.yml index b8e1b3136..c33b2364d 100644 --- a/rules/crowdstrike_rules/event_stream_rules/crowdstrike_ephemeral_user_account.yml +++ b/rules/crowdstrike_rules/event_stream_rules/crowdstrike_ephemeral_user_account.yml @@ -19,11 +19,12 @@ Detection: - ID: User Created FOLLOWED BY User Deleted From: AccountCreated To: AccountDeleted + WithinTimeFrameMinutes: 720 # 12 hours Match: - On: p_alert_context.target_name - LookbackWindowMinutes: 720 # 12 hours + LookbackWindowMinutes: 2160 Schedule: - RateMinutes: 480 # 8 hours + RateMinutes: 1440 TimeoutMinutes: 1 Tests: - Name: User Creation, Followed By Deletion diff --git a/rules/crowdstrike_rules/event_stream_rules/crowdstrike_new_admin_user_created.yml b/rules/crowdstrike_rules/event_stream_rules/crowdstrike_new_admin_user_created.yml index 9fa576129..3b9e2d488 100644 --- a/rules/crowdstrike_rules/event_stream_rules/crowdstrike_new_admin_user_created.yml +++ b/rules/crowdstrike_rules/event_stream_rules/crowdstrike_new_admin_user_created.yml @@ -19,11 +19,12 @@ Detection: - ID: AcountCreated FOLLOWED BY AdminRoleAssigned ON target AND actor From: AccountCreated To: AdminRoleAssigned + WithinTimeFrameMinutes: 45 Match: - On: p_alert_context.actor_target - LookbackWindowMinutes: 45 + LookbackWindowMinutes: 2160 Schedule: - RateMinutes: 30 + RateMinutes: 1440 TimeoutMinutes: 1 Tests: - Name: User Creation, Followed By Role Assignment 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" + }