diff --git a/filters/antivirus/sentinel-one.yml b/filters/antivirus/sentinel-one.yml index 38bfbb0a6..d78a12aa3 100644 --- a/filters/antivirus/sentinel-one.yml +++ b/filters/antivirus/sentinel-one.yml @@ -1,4 +1,4 @@ -# SentinelOne filter, version 3.0.1 +# SentinelOne filter, version 3.2.0 # Supports CEF Syslog format and [something@number something="xxxx"] format # Based on https://docs.centrify.com/Content/IntegrationContent/SIEM/arcsight-cef/arcsight-cef-format.htm # and https://docs.fortinet.com/document/fortisiem/6.1.0/external-systems-configuration-guide/298395/sentinelone @@ -362,25 +362,22 @@ pipeline: - log.rt to: log.ruleTime - # Adding action result + # Adding action result. Only an explicit failed mitigation or operation + # status is a final outcome: exact values, not substrings, and + # contradictory statuses stay unknown. A successful remediation is not an + # allowed connection, and console events have no final result, so + # neither sets one. - add: function: string params: key: actionResult - value: "failed" - where: 'equals("log.threatStatus", "mitigation_failed")' - - add: - function: string - params: - key: actionResult - value: "failed" - where: 'equals("log.status", "failed") && !exists("actionResult")' - - add: - function: string - params: - key: actionResult - value: "failed" - where: 'contains("log.mitigationStatus", "fail") && !exists("actionResult")' + value: "failure" + where: >- + (equalsIgnoreCase("log.threatStatus", "mitigation_failed") || + equalsIgnoreCase("log.status", "failed") || + regexMatch("log.mitigationStatus", "(?i)^(failed|failure|mitigation_failed)$")) && + !regexMatch("log.mitigationStatus", "(?i)^(success|succeeded|mitigated)$") && + !regexMatch("log.status", "(?i)^(success|succeeded)$") # Removing unused fields - delete: diff --git a/plugins/alerts/sentinel_one_action_result_test.go b/plugins/alerts/sentinel_one_action_result_test.go new file mode 100644 index 000000000..d56b6e199 --- /dev/null +++ b/plugins/alerts/sentinel_one_action_result_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/threatwinds/go-sdk/plugins" + "github.com/threatwinds/go-sdk/utils" + "google.golang.org/protobuf/encoding/protojson" +) + +// The raw fixture is also run through the isolated EventProcessor playground. +// This test checks the ordered verdict steps with the branch's actual CEL SDK. +func TestSentinelOneActionResultContract(t *testing.T) { + var cases []struct { + Name string `json:"name"` + Result string `json:"result"` + Expected map[string]string `json:"expected"` + } + data, err := os.ReadFile("testdata/sentinel_one_action_result.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + if len(cases) < 8 { + t.Fatal("missing outcome regression classes") + } + filterYAML, err := utils.ReadPbYaml("../../filters/antivirus/sentinel-one.yml") + if err != nil { + t.Fatal(err) + } + filter := new(plugins.Config) + if err := protojson.Unmarshal(filterYAML, filter); err != nil { + t.Fatal(err) + } + cache := plugins.NewCELCache("sentinel-one-final-outcome") + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + logFields := map[string]any{} + event := map[string]any{"log": logFields} + for path, value := range tc.Expected { + if strings.HasPrefix(path, "log.") { + logFields[strings.TrimPrefix(path, "log.")] = value + } + } + seenAdd := false + for _, stage := range filter.Pipeline { + if len(stage.DataTypes) != 1 || stage.DataTypes[0] != "antivirus-sentinel-one" { + continue + } + for _, step := range stage.Steps { + if step.Add == nil || step.Add.Params["key"].GetStringValue() != "actionResult" { + continue + } + seenAdd = true + state, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + matched, err := cache.Eval(step.Add.Where, string(state)) + if err != nil { + t.Fatal(err) + } + if matched { + event["actionResult"] = step.Add.Params["value"].GetStringValue() + } + } + } + if !seenAdd { + t.Fatal("missing final result mapping") + } + got, exists := event["actionResult"] + if tc.Result == "" { + if exists { + t.Fatalf("unexpected actionResult: %v", got) + } + } else if !exists || got != tc.Result { + t.Fatalf("actionResult = %v, want %q", got, tc.Result) + } + state, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + for _, result := range []string{"success", "failure", "denied"} { + matched, err := cache.Eval(`equals("actionResult","`+result+`")`, string(state)) + if err != nil || matched != (tc.Result == result) { + t.Errorf("%s predicate = %v (%v)", result, matched, err) + } + } + }) + } +} diff --git a/plugins/alerts/testdata/sentinel_one_action_result.json b/plugins/alerts/testdata/sentinel_one_action_result.json new file mode 100644 index 000000000..70dab3d16 --- /dev/null +++ b/plugins/alerts/testdata/sentinel_one_action_result.json @@ -0,0 +1,116 @@ +[ + { + "name": "mitigation-status-failed", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|9999|Synthetic mitigation failed|1|suser=analyst@example.com cat=Threat rt=#arcsightDate(Wed, 23 Sep 2026, 12:34:56 UTC) mitigationStatus=failed", + "result": "failure", + "expected": { + "log.mitigationStatus": "failed", + "log.cat": "Threat" + } + }, + { + "name": "threat-status-mitigation-failed", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|9999|Synthetic mitigation failed|1|suser=analyst@example.com cat=Threat rt=#arcsightDate(Wed, 23 Sep 2026, 12:34:56 UTC) threatStatus=mitigation_failed", + "result": "failure", + "expected": { + "log.threatStatus": "mitigation_failed", + "log.cat": "Threat" + } + }, + { + "name": "operation-status-failed", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|9999|Synthetic operation failed|1|suser=analyst@example.com cat=Threat rt=#arcsightDate(Wed, 23 Sep 2026, 12:34:56 UTC) status=failed", + "result": "failure", + "expected": { + "log.status": "failed", + "log.cat": "Threat" + } + }, + { + "name": "mitigation-status-failure-uppercase", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|9999|Synthetic mitigation failed|1|suser=analyst@example.com cat=Threat rt=#arcsightDate(Wed, 23 Sep 2026, 12:34:56 UTC) mitigationStatus=FAILURE", + "result": "failure", + "expected": { + "log.mitigationStatus": "FAILURE", + "log.cat": "Threat" + } + }, + { + "name": "composite-status-not-a-verdict", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|9999|Synthetic status|1|suser=analyst@example.com cat=Threat rt=#arcsightDate(Wed, 23 Sep 2026, 12:34:56 UTC) mitigationStatus=not_failed", + "result": "", + "expected": { + "log.mitigationStatus": "not_failed" + }, + "absent": [ + "actionResult" + ] + }, + { + "name": "conflicting-statuses-remain-unknown", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|9999|Synthetic conflict|1|suser=analyst@example.com cat=Threat rt=#arcsightDate(Wed, 23 Sep 2026, 12:34:56 UTC) threatStatus=mitigation_failed mitigationStatus=mitigated", + "result": "", + "expected": { + "log.threatStatus": "mitigation_failed", + "log.mitigationStatus": "mitigated" + }, + "absent": [ + "actionResult" + ] + }, + { + "name": "successful-remediation-not-an-allowed-connection", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|9999|Synthetic remediation|1|suser=analyst@example.com cat=Threat rt=#arcsightDate(Wed, 23 Sep 2026, 12:34:56 UTC) status=success mitigationStatus=mitigated", + "result": "", + "expected": { + "log.status": "success", + "log.mitigationStatus": "mitigated" + }, + "absent": [ + "actionResult" + ] + }, + { + "name": "administrative-event-has-no-outcome", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|37|Synthetic role assigned|1|suser=admin@example.com cat=SystemEvent rt=#arcsightDate(Wed, 23 Sep 2026, 12:34:56 UTC) activityID=100000000000000001 activityType=37 siteId=100000000000000002 siteName=Example Site accountId=100000000000000003 accountName=Example notificationScope=SITE", + "result": "", + "expected": { + "log.activityType": "37", + "log.cat": "SystemEvent" + }, + "absent": [ + "actionResult", + "origin.ip", + "target.ip" + ] + }, + { + "name": "console-notification-address-not-a-peer", + "raw": "<14>2026-09-23 12:34:56,000 testhost - CEF:0|SentinelOne|Mgmt|S-99.1.1#10|3660|Synthetic notification triggered|1|value=true issuer=console user_id=100000000000000004 username=Synthetic User online_agents=3 offline_agents=2 cat=SystemEvent deviceAddress=203.0.113.10 deviceHostFqdn=console.example.com deviceHostName=console.example.com notificationScope=SITE siteId=100000000000000002 siteName=Example Site accountId=100000000000000003 accountName=Example vendor=SentinelOne eventID=3660 eventDesc=Synthetic notification triggered eventSeverity=1 activityID=100000000000000005 activityType=3660", + "result": "", + "expected": { + "log.activityType": "3660", + "log.cat": "SystemEvent", + "log.deviceAddress": "203.0.113.10" + }, + "absent": [ + "actionResult", + "origin.ip", + "target.ip" + ] + }, + { + "name": "transport-line-without-outcome", + "raw": "<134>1 2026-09-23T12:34:56-04:00 testhost synthetic-load - - - SYNTHETIC-0001 seq=1", + "result": "", + "expected": { + "log.seq": "1", + "log.syslogPriority": "134" + }, + "absent": [ + "actionResult", + "origin.ip", + "target.ip" + ] + } +]