From b94c0337eeeddaf2fc69bf917c86f447b30f8487 Mon Sep 17 00:00:00 2001 From: Bharat <43651837+bcpenta@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:09:34 -0500 Subject: [PATCH 1/7] EKS Anonymous API Access Detection Rule (#1405) Co-authored-by: bcpenta Co-authored-by: Ben Airey Co-authored-by: Ariel Ropek <79653153+arielkr256@users.noreply.github.com> --- packs/aws.yml | 1 + rules/aws_eks_rules/anonymous_api_access.py | 30 +++++ rules/aws_eks_rules/anonymous_api_access.yml | 132 +++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 rules/aws_eks_rules/anonymous_api_access.py create mode 100644 rules/aws_eks_rules/anonymous_api_access.yml 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/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" + } From 9c745a64af4ae1c411dcb2aab1db10119be455e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:14:55 +0000 Subject: [PATCH 2/7] build(deps): bump thollander/actions-comment-pull-request from 3.0.0 to 3.0.1 (#1419) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ariel Ropek <79653153+arielkr256@users.noreply.github.com> --- .github/workflows/check-packs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 18e11fd522d24de8b8aa8ddfd98105133db7d81c Mon Sep 17 00:00:00 2001 From: ben-githubs <38414634+ben-githubs@users.noreply.github.com> Date: Mon, 4 Nov 2024 10:19:10 -0600 Subject: [PATCH 3/7] Adjust CR Schedules and Lookbacks (#1417) Co-authored-by: Ariel Ropek <79653153+arielkr256@users.noreply.github.com> --- ...ail_stopinstance_followed_by_modifyinstanceattributes.yml | 5 +++-- correlation_rules/aws_console_sign-in_without_okta.yml | 2 +- .../aws_privilege_escalation_via_user_compromise.yml | 5 +++-- .../aws_sso_access_token_retrieved_by_unauthenticated_ip.yml | 2 +- correlation_rules/aws_user_takeover_via_password_reset.yml | 5 +++-- ...p_cloud_run_service_create_followed_by_set_iam_policy.yml | 5 +++-- ...dvanced_security_change_not_followed_by_repo_archived.yml | 5 +++-- .../notion_login_followed_by_account_change.yml | 2 +- correlation_rules/okta_login_without_push.yml | 5 +++-- ...nelogin_successful_login_after_high_risk_failed_login.yml | 2 +- correlation_rules/potential_compromised_okta_credentials.yml | 5 +++-- correlation_rules/secret_exposed_and_not_quarantined.yml | 5 +++-- correlation_rules/snowflake_data_exfiltration.yml | 4 ++-- .../crowdstrike_ephemeral_user_account.yml | 5 +++-- .../crowdstrike_new_admin_user_created.yml | 5 +++-- 15 files changed, 36 insertions(+), 26 deletions(-) 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/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 From 9c91c75c9f03c0de5c8971bd37deefd027a72767 Mon Sep 17 00:00:00 2001 From: ben-githubs <38414634+ben-githubs@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:21:28 -0600 Subject: [PATCH 4/7] Convert Snowflake Scheduled Rules into Streaming Rules (#1387) Co-authored-by: Ariel Ropek <79653153+arielkr256@users.noreply.github.com> --- ...nowflake_potential_brute_force_success.yml | 47 ++++++++++++++ global_helpers/panther_snowflake_helpers.py | 11 ++++ global_helpers/panther_snowflake_helpers.yml | 5 ++ packs/snowflake_streaming.yml | 23 +++++++ ...snowflake_stream_account_admin_assigned.py | 12 ++++ ...nowflake_stream_account_admin_assigned.yml | 41 ++++++++++++ .../snowflake_stream_brute_force_by_ip.py | 18 ++++++ .../snowflake_stream_brute_force_by_ip.yml | 56 +++++++++++++++++ ...nowflake_stream_brute_force_by_username.py | 16 +++++ ...owflake_stream_brute_force_by_username.yml | 56 +++++++++++++++++ .../snowflake_stream_external_shares.py | 36 +++++++++++ .../snowflake_stream_external_shares.yml | 54 ++++++++++++++++ .../snowflake_stream_file_downloaded.py | 39 ++++++++++++ .../snowflake_stream_file_downloaded.yml | 46 ++++++++++++++ .../snowflake_stream_login_success.py | 2 + .../snowflake_stream_login_success.yml | 49 +++++++++++++++ .../snowflake_stream_login_without_mfa.py | 19 ++++++ .../snowflake_stream_login_without_mfa.yml | 54 ++++++++++++++++ .../snowflake_stream_public_role_grant.py | 9 +++ .../snowflake_stream_public_role_grant.yml | 60 ++++++++++++++++++ ...nowflake_stream_table_copied_into_stage.py | 26 ++++++++ ...owflake_stream_table_copied_into_stage.yml | 40 ++++++++++++ .../snowflake_stream_temp_stage_created.py | 33 ++++++++++ .../snowflake_stream_temp_stage_created.yml | 62 +++++++++++++++++++ .../snowflake_stream_user_created.py | 33 ++++++++++ .../snowflake_stream_user_created.yml | 31 ++++++++++ .../snowflake_stream_user_enabled.py | 35 +++++++++++ .../snowflake_stream_user_enabled.yml | 47 ++++++++++++++ 28 files changed, 960 insertions(+) create mode 100644 correlation_rules/snowflake_potential_brute_force_success.yml create mode 100644 global_helpers/panther_snowflake_helpers.py create mode 100644 global_helpers/panther_snowflake_helpers.yml create mode 100644 packs/snowflake_streaming.yml create mode 100644 rules/snowflake_rules/snowflake_stream_account_admin_assigned.py create mode 100644 rules/snowflake_rules/snowflake_stream_account_admin_assigned.yml create mode 100644 rules/snowflake_rules/snowflake_stream_brute_force_by_ip.py create mode 100644 rules/snowflake_rules/snowflake_stream_brute_force_by_ip.yml create mode 100644 rules/snowflake_rules/snowflake_stream_brute_force_by_username.py create mode 100644 rules/snowflake_rules/snowflake_stream_brute_force_by_username.yml create mode 100644 rules/snowflake_rules/snowflake_stream_external_shares.py create mode 100644 rules/snowflake_rules/snowflake_stream_external_shares.yml create mode 100644 rules/snowflake_rules/snowflake_stream_file_downloaded.py create mode 100644 rules/snowflake_rules/snowflake_stream_file_downloaded.yml create mode 100644 rules/snowflake_rules/snowflake_stream_login_success.py create mode 100644 rules/snowflake_rules/snowflake_stream_login_success.yml create mode 100644 rules/snowflake_rules/snowflake_stream_login_without_mfa.py create mode 100644 rules/snowflake_rules/snowflake_stream_login_without_mfa.yml create mode 100644 rules/snowflake_rules/snowflake_stream_public_role_grant.py create mode 100644 rules/snowflake_rules/snowflake_stream_public_role_grant.yml create mode 100644 rules/snowflake_rules/snowflake_stream_table_copied_into_stage.py create mode 100644 rules/snowflake_rules/snowflake_stream_table_copied_into_stage.yml create mode 100644 rules/snowflake_rules/snowflake_stream_temp_stage_created.py create mode 100644 rules/snowflake_rules/snowflake_stream_temp_stage_created.yml create mode 100644 rules/snowflake_rules/snowflake_stream_user_created.py create mode 100644 rules/snowflake_rules/snowflake_stream_user_created.yml create mode 100644 rules/snowflake_rules/snowflake_stream_user_enabled.py create mode 100644 rules/snowflake_rules/snowflake_stream_user_enabled.yml 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" + } From bdc7a6c51a7da1f69822eacc747508f2864cc4f8 Mon Sep 17 00:00:00 2001 From: Ariel Ropek <79653153+arielkr256@users.noreply.github.com> Date: Mon, 4 Nov 2024 10:44:01 -0700 Subject: [PATCH 5/7] Fix event dict typing (#1413) --- global_helpers/panther_aws_helpers.py | 4 ++-- global_helpers/panther_box_helpers.py | 2 +- global_helpers/panther_cloudflare_helpers.py | 4 ++-- global_helpers/panther_crowdstrike_fdr_helpers.py | 6 +++--- global_helpers/panther_duo_helpers.py | 2 +- global_helpers/panther_lookuptable_helpers.py | 2 +- global_helpers/panther_okta_helpers.py | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) 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", ""), From 4b6e48560e5df5cf3c18a375acd2b00439218315 Mon Sep 17 00:00:00 2001 From: James Cleverley-Prance Date: Mon, 4 Nov 2024 17:50:08 +0000 Subject: [PATCH 6/7] Fix Wiz Audit Log Titles for Service Account Actors (#1414) Co-authored-by: Ariel Ropek <79653153+arielkr256@users.noreply.github.com> --- global_helpers/panther_wiz_helpers.py | 28 +++++++++++++++++-- ...wiz_cicd_scan_policy_updated_or_deleted.py | 6 ++-- .../wiz_connector_updated_or_deleted.py | 6 ++-- .../wiz_data_classifier_updated_or_deleted.py | 6 ++-- ..._integrity_validator_updated_or_deleted.py | 6 ++-- .../wiz_integration_updated_or_deleted.py | 6 ++-- rules/wiz_rules/wiz_revoke_user_sessions.py | 6 ++-- .../wiz_rotate_service_account_secret.py | 6 ++-- rules/wiz_rules/wiz_rule_change.py | 6 ++-- .../wiz_saml_identity_provider_change.py | 6 ++-- rules/wiz_rules/wiz_service_account_change.py | 6 ++-- rules/wiz_rules/wiz_update_ip_restrictions.py | 6 ++-- rules/wiz_rules/wiz_update_login_settings.py | 6 ++-- .../wiz_rules/wiz_update_scanner_settings.py | 6 ++-- .../wiz_update_support_contact_list.py | 6 ++-- .../wiz_rules/wiz_user_created_or_deleted.py | 6 ++-- .../wiz_user_role_updated_or_deleted.py | 6 ++-- 17 files changed, 90 insertions(+), 34 deletions(-) diff --git a/global_helpers/panther_wiz_helpers.py b/global_helpers/panther_wiz_helpers.py index 39441b50a..96f498fe6 100644 --- a/global_helpers/panther_wiz_helpers.py +++ b/global_helpers/panther_wiz_helpers.py @@ -7,9 +7,33 @@ def wiz_success(event): def wiz_alert_context(event): return { "action": event.get("action", ""), - "user": event.get("user", ""), + "actor": wiz_actor(event), "source_ip": event.get("sourceip", ""), "event_id": event.get("id", ""), - "service_account": event.get("serviceaccount", ""), "action_parameters": event.get("actionparameters", ""), } + + +def wiz_actor(event): + user = event.get("user") + serviceaccount = event.get("serviceAccount") + + if user is not None: + return { + "type": "user", + "id": user.get("id"), + "name": user.get("name"), + } + + if serviceaccount is not None: + return { + "type": "serviceaccount", + "id": serviceaccount.get("id"), + "name": serviceaccount.get("name"), + } + + return { + "type": "unknown", + "id": "", + "name": "", + } diff --git a/rules/wiz_rules/wiz_cicd_scan_policy_updated_or_deleted.py b/rules/wiz_rules/wiz_cicd_scan_policy_updated_or_deleted.py index 30c256af9..6eef0f865 100644 --- a/rules/wiz_rules/wiz_cicd_scan_policy_updated_or_deleted.py +++ b/rules/wiz_rules/wiz_cicd_scan_policy_updated_or_deleted.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = ["DeleteCICDScanPolicy", "UpdateCICDScanPolicy"] @@ -10,9 +10,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_connector_updated_or_deleted.py b/rules/wiz_rules/wiz_connector_updated_or_deleted.py index 212d962cd..a45e0a2f7 100644 --- a/rules/wiz_rules/wiz_connector_updated_or_deleted.py +++ b/rules/wiz_rules/wiz_connector_updated_or_deleted.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = ["DeleteConnector", "UpdateConnector"] @@ -10,9 +10,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_data_classifier_updated_or_deleted.py b/rules/wiz_rules/wiz_data_classifier_updated_or_deleted.py index 19d531b25..122033d2e 100644 --- a/rules/wiz_rules/wiz_data_classifier_updated_or_deleted.py +++ b/rules/wiz_rules/wiz_data_classifier_updated_or_deleted.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = ["DeleteDataClassifier", "UpdateDataClassifier"] @@ -10,9 +10,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_image_integrity_validator_updated_or_deleted.py b/rules/wiz_rules/wiz_image_integrity_validator_updated_or_deleted.py index 6d770523f..2926fe968 100644 --- a/rules/wiz_rules/wiz_image_integrity_validator_updated_or_deleted.py +++ b/rules/wiz_rules/wiz_image_integrity_validator_updated_or_deleted.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = ["DeleteImageIntegrityValidator", "UpdateImageIntegrityValidator"] @@ -10,9 +10,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_integration_updated_or_deleted.py b/rules/wiz_rules/wiz_integration_updated_or_deleted.py index 8fa56f2aa..1873b6c90 100644 --- a/rules/wiz_rules/wiz_integration_updated_or_deleted.py +++ b/rules/wiz_rules/wiz_integration_updated_or_deleted.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = ["DeleteIntegration", "UpdateIntegration"] @@ -10,9 +10,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_revoke_user_sessions.py b/rules/wiz_rules/wiz_revoke_user_sessions.py index 79a05c4cd..1287af330 100644 --- a/rules/wiz_rules/wiz_revoke_user_sessions.py +++ b/rules/wiz_rules/wiz_revoke_user_sessions.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success def rule(event): @@ -8,9 +8,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_rotate_service_account_secret.py b/rules/wiz_rules/wiz_rotate_service_account_secret.py index 9577440df..076286c81 100644 --- a/rules/wiz_rules/wiz_rotate_service_account_secret.py +++ b/rules/wiz_rules/wiz_rotate_service_account_secret.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success def rule(event): @@ -8,9 +8,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_rule_change.py b/rules/wiz_rules/wiz_rule_change.py index 153fb0a3a..34b6112a5 100644 --- a/rules/wiz_rules/wiz_rule_change.py +++ b/rules/wiz_rules/wiz_rule_change.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = [ "DeleteAutomationRule", @@ -24,9 +24,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_saml_identity_provider_change.py b/rules/wiz_rules/wiz_saml_identity_provider_change.py index d183ed51b..b79de176c 100644 --- a/rules/wiz_rules/wiz_saml_identity_provider_change.py +++ b/rules/wiz_rules/wiz_saml_identity_provider_change.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = [ "UpdateSAMLIdentityProvider", @@ -15,9 +15,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_service_account_change.py b/rules/wiz_rules/wiz_service_account_change.py index b8faba6fd..474837cc6 100644 --- a/rules/wiz_rules/wiz_service_account_change.py +++ b/rules/wiz_rules/wiz_service_account_change.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = [ "CreateServiceAccount", @@ -14,9 +14,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_update_ip_restrictions.py b/rules/wiz_rules/wiz_update_ip_restrictions.py index 85337be52..98fc53b5f 100644 --- a/rules/wiz_rules/wiz_update_ip_restrictions.py +++ b/rules/wiz_rules/wiz_update_ip_restrictions.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success def rule(event): @@ -8,9 +8,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_update_login_settings.py b/rules/wiz_rules/wiz_update_login_settings.py index b5cb8ddf1..9498a607a 100644 --- a/rules/wiz_rules/wiz_update_login_settings.py +++ b/rules/wiz_rules/wiz_update_login_settings.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success def rule(event): @@ -8,9 +8,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_update_scanner_settings.py b/rules/wiz_rules/wiz_update_scanner_settings.py index b033999ab..0d9b61ee1 100644 --- a/rules/wiz_rules/wiz_update_scanner_settings.py +++ b/rules/wiz_rules/wiz_update_scanner_settings.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success def rule(event): @@ -8,9 +8,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_update_support_contact_list.py b/rules/wiz_rules/wiz_update_support_contact_list.py index 00e65ae67..e0bde5d1a 100644 --- a/rules/wiz_rules/wiz_update_support_contact_list.py +++ b/rules/wiz_rules/wiz_update_support_contact_list.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success def rule(event): @@ -8,9 +8,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_user_created_or_deleted.py b/rules/wiz_rules/wiz_user_created_or_deleted.py index 32dd14cfd..e6e38998d 100644 --- a/rules/wiz_rules/wiz_user_created_or_deleted.py +++ b/rules/wiz_rules/wiz_user_created_or_deleted.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = ["CreateUser", "DeleteUser"] @@ -10,9 +10,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) diff --git a/rules/wiz_rules/wiz_user_role_updated_or_deleted.py b/rules/wiz_rules/wiz_user_role_updated_or_deleted.py index ce336fe37..99bc6c61a 100644 --- a/rules/wiz_rules/wiz_user_role_updated_or_deleted.py +++ b/rules/wiz_rules/wiz_user_role_updated_or_deleted.py @@ -1,4 +1,4 @@ -from panther_wiz_helpers import wiz_alert_context, wiz_success +from panther_wiz_helpers import wiz_actor, wiz_alert_context, wiz_success SUSPICIOUS_ACTIONS = ["DeleteUserRole", "UpdateUserRole"] @@ -10,9 +10,11 @@ def rule(event): def title(event): + actor = wiz_actor(event) + return ( f"[Wiz]: [{event.get('action', 'ACTION_NOT_FOUND')}] action " - f"performed by user [{event.deep_get('user', 'name', default='USER_NAME_NOT_FOUND')}]" + f"performed by {actor.get('type')} [{actor.get('name')}]" ) From 5132f6afb241ade49078d23b1f215e6f70b930b5 Mon Sep 17 00:00:00 2001 From: Panos Sakkos Date: Wed, 6 Nov 2024 21:41:50 +0200 Subject: [PATCH 7/7] Update CONTRIBUTING.md (#1420) --- CONTRIBUTING.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24ab6793e..7fa3b49f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to `panther-analysis` -Thank you for your interest in contributing to Panther's open-source ruleset! We appreciate all types of contributions, including new detection rules, feature requests, and bug reports. +Thank you for your interest in contributing to Panther's open-source ruleset! We appreciate all types of contributions, including new detection rules, feature requests, and bug reports. ## What makes a good detection? @@ -19,18 +19,18 @@ Before submitting your pull request, make sure to: - Write or update relevant unit tests - Redact any sensitive information or PII from example logs - Format, lint, and test your changes to ensure CI tests pass, using the following commands: - ```bash - make fmt - make lint - make test - ``` + ```bash + make fmt + make lint + make test + ``` ## Pull Request process 1. Make desired detection changes. This may include creating new detections in existing log type directories, creating new log type directories, updating existing detections, etc 2. Commit both the Python and Metadata files 3. Write a clear commit message -4. Open a [Pull Request](https://github.com/panther-labs/panther-analysis/pulls). +4. Open a [Pull Request](https://github.com/panther-labs/panther-analysis/pulls) against the `develop` branch. 5. Once your PR has been approved by code owners, if you have merge permissions, merge it. If you do not have merge permissions, leave a comment requesting a code owner merge it for you ## Code of Conduct @@ -42,4 +42,5 @@ in all of your interactions with this project. If you need assistance at any point, feel free to open a support ticket, or reach out to us on [Panther Community Slack](https://pnthr.io/community). -Thank you again for your contributions, and we look forward to working together! \ No newline at end of file +Thank you again for your contributions, and we look forward to working together! +