From 768e4a77e381166b84f0bb51a29f6dc082505408 Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 19:18:07 -0400 Subject: [PATCH 1/3] fix(sonicwall): recover addresses, firewall actions and event outcomes The address step splits src and dst only as ip:port:interface. The grok step writes nothing unless every pattern matches and the filter then deletes the value, so ip:port (VPN negotiation events) and ip::interface (user logins) lose their address. When fw_action is the last key, the rescue grok cannot bound it and the fallback rename reads log.fwaction, while go-sdk v1.1.35 and later keep the underscore, so those events get no action and no outcome. Events whose firewall action is NA get no outcome at all, and the CEF header step captures its fields with a lone lazy pattern, which the grok step rejects. Each address shape now has its own step, and the original value is deleted only after an address was read. A fallback reads fw_action under both spellings. Events without a firewall decision get an outcome from the meaning of their message: allowed logins and completed IKEv2 negotiations give success, as does Connection Closed when bytes came back; bad credentials, RADIUS, LDAP, XAUTH and SSO failures and failed IKE negotiations give failure; policy and zone refusals give denied. The CEF header fields use a non-empty pattern. The administrator authentication-failure rule counted "Administrator login allowed" (29) as a failure and, through its message branch, SSO probe failures and policy denials. It now counts logins denied due to bad credentials with a failure outcome, its history counts failures from the same address, and alerts group by address, so a password spray is one alert instead of one per user name. Co-Authored-By: Claude Opus 5.5 --- filters/sonicwall/sonic_wall.yml | 132 +++++++++++++++--- .../sonicwall_admin_auth_failures.yml | 17 +-- 2 files changed, 120 insertions(+), 29 deletions(-) diff --git a/filters/sonicwall/sonic_wall.yml b/filters/sonicwall/sonic_wall.yml index d20d7b247..bf5b5699f 100644 --- a/filters/sonicwall/sonic_wall.yml +++ b/filters/sonicwall/sonic_wall.yml @@ -1,4 +1,4 @@ -# SonicWall Firewall — version 4.0.0 +# SonicWall Firewall — version 4.1.0 # # Formats supported # - SonicOS syslog KV: id=firewall sn=... time="..." fw=... msg="..." src=IP:PORT:IF ... @@ -35,6 +35,8 @@ pipeline: # ------------------------------------------------------------------- # CEF header (only when the log line contains "CEF:") + # The grok step matches each pattern alone and rejects an empty match, + # so header fields use a non-empty pattern instead of a lazy one. # ------------------------------------------------------------------- - grok: source: raw @@ -46,27 +48,27 @@ pipeline: - fieldName: "" pattern: '\|' - fieldName: log.deviceVendor - pattern: '{{.data}}' + pattern: '(?:[^|\\]|\\.)+' - fieldName: "" pattern: '\|' - fieldName: log.deviceProduct - pattern: '{{.data}}' + pattern: '(?:[^|\\]|\\.)+' - fieldName: "" pattern: '\|' - fieldName: log.deviceVersion - pattern: '{{.data}}' + pattern: '(?:[^|\\]|\\.)+' - fieldName: "" pattern: '\|' - fieldName: log.eventCode - pattern: '{{.data}}' + pattern: '(?:[^|\\]|\\.)+' - fieldName: "" pattern: '\|' - fieldName: log.eventName - pattern: '{{.data}}' + pattern: '(?:[^|\\]|\\.)+' - fieldName: "" pattern: '\|' - fieldName: log.cefSeverity - pattern: '{{.data}}' + pattern: '(?:[^|\\]|\\.)+' - fieldName: "" pattern: '\|' where: contains("raw", "CEF:") @@ -202,10 +204,6 @@ pipeline: pattern: '{{.greedy}}' where: contains("raw", "uuid=\"") - - delete: - fields: - - log.fwaction - where: contains("raw", "fw_action=\"") - grok: source: raw patterns: @@ -351,8 +349,10 @@ pipeline: where: exists("log.deviceTimeRaw") # ------------------------------------------------------------------- - # Split src / dst => ip[:port[:iface]] + # Split src / dst => ip[:port[:iface]], ip:port or ip::iface. + # Each shape has its own step: a step writes only when all its patterns match. # ------------------------------------------------------------------- + # ip:port:interface - grok: source: log.src patterns: @@ -366,18 +366,43 @@ pipeline: pattern: ':' - fieldName: log.sourceInterface pattern: '{{.word}}' - where: contains("log.src", ":") + where: regexMatch("log.src", "^[0-9.]+:[0-9]+:.") + # ip:port, used by VPN negotiation events + - grok: + source: log.src + patterns: + - fieldName: origin.ip + pattern: '{{.ipv4}}' + - fieldName: "" + pattern: ':' + - fieldName: origin.port + pattern: '{{.integer}}' + where: regexMatch("log.src", "^[0-9.]+:[0-9]+$") + # ip::interface, used by user login events + - grok: + source: log.src + patterns: + - fieldName: origin.ip + pattern: '{{.ipv4}}' + - fieldName: "" + pattern: '::' + - fieldName: log.sourceInterface + pattern: '{{.word}}' + where: regexMatch("log.src", "^[0-9.]+::.") - delete: fields: - log.src - where: contains("log.src", ":") + where: exists("origin.ip") + # A bare address; any other value stays under its vendor key. - rename: from: - log.src to: origin.ip + where: inCIDR("log.src", "0.0.0.0/0") + # ip:port:interface - grok: source: log.dst patterns: @@ -391,17 +416,41 @@ pipeline: pattern: ':' - fieldName: log.targetInterface pattern: '{{.word}}' - where: contains("log.dst", ":") + where: regexMatch("log.dst", "^[0-9.]+:[0-9]+:.") + # ip:port, used by VPN negotiation events + - grok: + source: log.dst + patterns: + - fieldName: target.ip + pattern: '{{.ipv4}}' + - fieldName: "" + pattern: ':' + - fieldName: target.port + pattern: '{{.integer}}' + where: regexMatch("log.dst", "^[0-9.]+:[0-9]+$") + # ip::interface, used by user login events + - grok: + source: log.dst + patterns: + - fieldName: target.ip + pattern: '{{.ipv4}}' + - fieldName: "" + pattern: '::' + - fieldName: log.targetInterface + pattern: '{{.word}}' + where: regexMatch("log.dst", "^[0-9.]+::.") - delete: fields: - log.dst - where: contains("log.dst", ":") + where: exists("target.ip") + # A bare address; any other value stays under its vendor key. - rename: from: - log.dst to: target.ip + where: inCIDR("log.dst", "0.0.0.0/0") - grok: source: log.natSrc @@ -587,6 +636,15 @@ pipeline: - log.cs6 to: log.threatContext + # fw_action is often the last key, which the rescue above cannot bound. + # Fall back to the KV value; go-sdk v1.1.35 and later keep the underscore. + - rename: + from: + - log.fw_action + - log.fwaction + to: log.actionRaw + where: '!exists("log.actionRaw")' + # ------------------------------------------------------------------- # Strip residual quotes / apostrophes from rescued values. # ------------------------------------------------------------------- @@ -638,11 +696,6 @@ pipeline: - log.userRaw to: origin.user - - rename: - from: - - log.fwaction - to: action - # ------------------------------------------------------------------- # Type casts # ------------------------------------------------------------------- @@ -685,6 +738,42 @@ pipeline: key: actionResult value: denied where: oneOf("action", ["drop", "dropped", "deny", "denied", "block", "blocked"]) + # Events without a firewall decision, by the meaning of their message: + # 29, 31, 236, 237, 238 and 1080 "... login allowed"; 942 "IKEv2 + # Authentication successful"; 978 "IKEv2 negotiation complete". + - add: + function: string + params: + key: actionResult + value: success + where: '!exists("actionResult") && oneOf("log.eventCode", ["29", "31", "236", "237", "238", "1080", "942", "978"])' + # 537 "Connection Closed" follows an allowed connection; only one that + # received data back completed. Without received bytes it keeps no outcome. + - add: + function: string + params: + key: actionResult + value: success + where: '!exists("actionResult") && equals("log.eventCode", "537") && greaterThan("origin.bytesReceived", 0)' + # 32, 33 and 200 "... login denied due to bad credentials"; 34 "Pending + # login timed out"; 140 "XAUTH Failed ... Authentication failure"; 243, 244, + # 746 and 747 RADIUS or LDAP failures; 1035 "password expired"; 1117 "SSO + # probe failed"; 402, 658, 953, 967 and 1305 failed IKE negotiations; and + # any other "... denied due to bad credentials" message. + - add: + function: string + params: + key: actionResult + value: failure + where: '!exists("actionResult") && (oneOf("log.eventCode", ["32", "33", "34", "140", "200", "243", "244", "746", "747", "1035", "1117", "402", "658", "953", "967", "1305"]) || contains("log.message", "denied due to bad credentials"))' + # 986 "User login denied - not allowed by Policy rule"; 1079 "SSLVPN + # service is not allowed on ". + - add: + function: string + params: + key: actionResult + value: denied + where: '!exists("actionResult") && oneOf("log.eventCode", ["986", "1079"])' # ------------------------------------------------------------------- # Group category human label from log.groupCategoryId (1..17) @@ -851,4 +940,5 @@ pipeline: - log.syslogPri - log.cefVersion - log.fwaction + - log.fw_action - log.grokTrash diff --git a/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml b/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml index 1ba922bd0..66a254536 100644 --- a/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml +++ b/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml @@ -1,4 +1,4 @@ -# Rule version v2.1.0 +# Rule version v2.2.0 dataTypes: - firewall-sonicwall @@ -17,6 +17,8 @@ description: | Detects failed authentication attempts against the SonicWall management interface (GUI/CLI/API). Repeated failures from a single source IP suggest a brute-force or credential-stuffing attack targeting admin credentials. + It counts logins denied due to bad credentials (events 32, 33 and 200), + not SSO probe failures, policy denials or allowed logins. Next Steps: 1. Investigate the source IP — geolocation, reputation, previous activity. @@ -26,20 +28,19 @@ description: | 4. Restrict management access to specific IP ranges and enforce MFA. where: | ( - oneOf("log.eventCode", ["29", "32", "33", "1246"]) || - ( - contains("log.message", ["admin", "administrator", "login", "management"]) && - contains("log.message", ["failed", "fail", "denied", "invalid", "incorrect"]) - ) - ) && exists("origin.ip") + oneOf("log.eventCode", ["32", "33", "200"]) || + contains("log.message", "denied due to bad credentials") + ) && equals("actionResult", "failure") && exists("origin.ip") afterEvents: - indexPattern: v11-log-firewall-sonicwall-* with: - field: origin.ip operator: filter_term value: '{{.origin.ip}}' + - field: actionResult + operator: filter_term + value: failure within: 15m count: 5 groupBy: - adversary.ip - - adversary.user From 90f18d050367abaf8def001edc5684345dcd5c22 Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 19:33:51 -0400 Subject: [PATCH 2/3] fix(sonicwall): deduplicate bad-credential alerts by source address Grouping by address only nests repeats: every attempt that passes the history check still creates a child alert. On the busiest day in the last week one deployment had 41,227 bad-credential denials from 41 addresses, which would create 40,756 alerts. Deduplicating by address creates 15 and keeps one alert per source for seven days. Co-Authored-By: Claude Opus 5.5 --- .../sonicwall_firewall/sonicwall_admin_auth_failures.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml b/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml index 66a254536..90666f784 100644 --- a/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml +++ b/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml @@ -18,7 +18,8 @@ description: | interface (GUI/CLI/API). Repeated failures from a single source IP suggest a brute-force or credential-stuffing attack targeting admin credentials. It counts logins denied due to bad credentials (events 32, 33 and 200), - not SSO probe failures, policy denials or allowed logins. + not SSO probe failures, policy denials or allowed logins, and raises one + alert per source address for seven days. Next Steps: 1. Investigate the source IP — geolocation, reputation, previous activity. @@ -42,5 +43,5 @@ afterEvents: value: failure within: 15m count: 5 -groupBy: +deduplicateBy: - adversary.ip From 2f777e20e2daf4c8a7fff4f80074e319d759b951 Mon Sep 17 00:00:00 2001 From: Osmany Montero Date: Fri, 25 Sep 2026 17:06:41 +0100 Subject: [PATCH 3/3] test(sonicwall): add committed raw-replay contract test for #2743 #2743 shipped no in-repo test (all evidence was out-of-band playground). Adds sonicwall_contract_test.go: an engine-faithful grok model (mirrors the merged fortigate contract test) that replays 6 admin-auth logins through the ordered filter and asserts origin.ip/origin.port/log.eventCode/actionResult plus that the shipped admin_auth_failures where: clause fires on the bad-credential cases (32/33/200) and not on allowed (29) / policy-denied (986). Verified discriminating: PASSES on this filter, FAILS on the base v11 filter (origin.ip and actionResult absent). Built at #2743's head. --- plugins/alerts/sonicwall_contract_test.go | 352 ++++++++++++++++++ .../testdata/sonicwall_admin_auth_raw.json | 67 ++++ 2 files changed, 419 insertions(+) create mode 100644 plugins/alerts/sonicwall_contract_test.go create mode 100644 plugins/alerts/testdata/sonicwall_admin_auth_raw.json diff --git a/plugins/alerts/sonicwall_contract_test.go b/plugins/alerts/sonicwall_contract_test.go new file mode 100644 index 000000000..a68897b5d --- /dev/null +++ b/plugins/alerts/sonicwall_contract_test.go @@ -0,0 +1,352 @@ +package main + +// Offline SonicWall extraction model, not the closed EventProcessor. +// Explicit YAML grok/kv/rename/trim/cast/add/delete steps are modeled; grok +// follows the EventProcessor step (a pattern list compiles to one regex and +// writes its named fields only when every group matches a non-empty value). +// CEL, the KV split and cast use the go.mod SDK. External geolocation is not +// executed. +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + "testing" + "text/template" + + "github.com/threatwinds/go-sdk/plugins" + "github.com/threatwinds/go-sdk/utils" + "google.golang.org/protobuf/encoding/protojson" +) + +const sonicDataType = "firewall-sonicwall" + +type sonicFixture struct { + Name string `json:"name"` + DataSource string `json:"dataSource"` + Raw string `json:"raw"` + Expected map[string]any `json:"expected"` + Absent []string `json:"absent"` + MatchRule string `json:"matchRule"` // rule whose where: must evaluate true +} + +func sonicPut(m map[string]any, path string, value any, remove bool) { + p := strings.Split(path, ".") + for _, k := range p[:len(p)-1] { + n, ok := m[k].(map[string]any) + if !ok { + if remove { + return + } + n = map[string]any{} + m[k] = n + } + m = n + } + if remove { + delete(m, p[len(p)-1]) + } else { + m[p[len(p)-1]] = value + } +} + +func sonicGet(m map[string]any, p string) (any, bool) { + var v any = m + for _, k := range strings.Split(p, ".") { + n, ok := v.(map[string]any) + if !ok { + return nil, false + } + v, ok = n[k] + if !ok { + return nil, false + } + } + return v, true +} + +func sonicConfig(t *testing.T) *plugins.Config { + t.Helper() + b, e := utils.ReadPbYaml("../../filters/sonicwall/sonic_wall.yml") + if e != nil { + t.Fatal(e) + } + c := new(plugins.Config) + if e = protojson.Unmarshal(b, c); e != nil { + t.Fatal(e) + } + return c +} + +func sonicRegex(t *testing.T, g *plugins.Grok, cfg *plugins.Config) *regexp.Regexp { + t.Helper() + var pattern strings.Builder + for i, p := range g.Patterns { + if p.FieldName != "" { + fmt.Fprintf(&pattern, "(?P%s)", i, p.Pattern) + } else { + pattern.WriteString("(?:" + p.Pattern + ")") + } + } + tmpl, e := template.New("grok").Option("missingkey=error").Parse(pattern.String()) + if e != nil { + t.Fatal(e) + } + // grok named captures reference the filter's local/built-in patterns. + pats := map[string]string{ + "greedy": ".*", + "data": ".*?", + "word": `[A-Za-z0-9_-]+`, + "space": `\s+`, + "integer": `[0-9]+`, + "ipv4": `(?:[0-9]{1,3}\.){3}[0-9]{1,3}`, + } + for k, v := range cfg.Patterns { + pats[k] = v + } + var b bytes.Buffer + if e = tmpl.Execute(&b, pats); e != nil { + t.Fatal(e) + } + r, e := regexp.Compile(b.String()) + if e != nil { + t.Fatal(e) + } + return r +} + +// swGrok mirrors the EventProcessor: the pattern list is compiled to one +// regex; every named field and every anonymous group must match a non-empty +// value for the step to write anything. +func sonicGrokMatches(r *regexp.Regexp, s string, g *plugins.Grok) (map[int]string, bool) { + m := r.FindStringSubmatch(s) + if m == nil { + return nil, false + } + out := map[int]string{} + for i, p := range g.Patterns { + if p.FieldName == "" { + continue + } + gi := r.SubexpIndex(fmt.Sprintf("f%d", i)) + if gi < 0 { + return nil, false + } + if m[gi] == "" { + return nil, false + } + out[i] = m[gi] + } + return out, true +} + +func sonicParse(t *testing.T, cfg *plugins.Config, raw string, dataSource string, cache *plugins.CELCache) string { + t.Helper() + draft := map[string]any{"raw": raw, "dataType": sonicDataType, "dataSource": dataSource, "log": map[string]any{}} + for _, stage := range cfg.Pipeline { + matched := false + for _, dt := range stage.DataTypes { + if dt == sonicDataType { + matched = true + } + } + if !matched { + continue + } + for _, s := range stage.Steps { + b, e := protojson.Marshal(s) + if e != nil { + t.Fatal(e) + } + var step map[string]map[string]any + if e = json.Unmarshal(b, &step); e != nil { + t.Fatal(e) + } + for kind, body := range step { + if w, ok := body["where"].(string); ok && w != "" { + snapshot, err := json.Marshal(draft) + if err != nil { + t.Fatal(err) + } + match, e := cache.Eval(w, string(snapshot)) + if e != nil { + t.Fatal(e) + } + if !match { + continue + } + } + switch kind { + case "grok": + g := s.Grok + src := g.Source + if src == "" { + src = "raw" + } + v, ok := sonicGet(draft, src) + if !ok { + continue + } + str, ok := v.(string) + if !ok { + t.Fatalf("non-string grok source %s", src) + } + r := sonicRegex(t, g, cfg) + vals, ok := sonicGrokMatches(r, str, g) + if !ok { + continue + } + for i, p := range g.Patterns { + if p.FieldName != "" { + sonicPut(draft, p.FieldName, vals[i], false) + } + } + case "rename": + for _, p := range s.Rename.From { + if v, ok := sonicGet(draft, p); ok { + sonicPut(draft, s.Rename.To, v, false) + sonicPut(draft, p, nil, true) + break + } + } + case "trim": + for _, p := range s.Trim.Fields { + if v, ok := sonicGet(draft, p); ok { + str, ok := v.(string) + if !ok { + continue + } + switch s.Trim.Function { + case "prefix": + str = strings.TrimPrefix(str, s.Trim.Substring) + case "suffix": + str = strings.TrimSuffix(str, s.Trim.Substring) + default: + t.Fatalf("unsupported trim %s", s.Trim.Function) + } + sonicPut(draft, p, str, false) + } + } + case "add": + sonicPut(draft, s.Add.Params["key"].GetStringValue(), s.Add.Params["value"].AsInterface(), false) + case "delete": + for _, p := range s.Delete.Fields { + sonicPut(draft, p, nil, true) + } + case "kv": + v, ok := sonicGet(draft, s.Kv.Source) + if !ok { + continue + } + for _, item := range strings.Split(v.(string), s.Kv.FieldSplit) { + pair := strings.SplitN(item, s.Kv.ValueSplit, 2) + if len(pair) != 2 { + continue + } + key := pair[0] + utils.SanitizeField(&key) + if key != "" { + sonicPut(draft, "log."+key, pair[1], false) + } + } + case "dynamic": + // external geolocation service is not executed + case "cast": + for _, field := range s.Cast.Fields { + if value, ok := sonicGet(draft, field); ok { + switch s.Cast.To { + case "string": + sonicPut(draft, field, utils.CastString(value), false) + case "int": + sonicPut(draft, field, utils.CastInt64(value), false) + case "float": + sonicPut(draft, field, utils.CastFloat64(value), false) + default: + t.Fatalf("unsupported cast %s", s.Cast.To) + } + } + } + default: + t.Fatalf("unmodeled step kind %q", kind) + } + } + } + } + out, e := json.Marshal(draft) + if e != nil { + t.Fatal(e) + } + return string(out) +} + +func TestSonicWallRawContracts(t *testing.T) { + data, err := os.ReadFile("testdata/sonicwall_admin_auth_raw.json") + if err != nil { + t.Fatal(err) + } + var fixtures []sonicFixture + if err := json.Unmarshal(data, &fixtures); err != nil { + t.Fatal(err) + } + cfg := sonicConfig(t) + cache := plugins.NewCELCache("sonicwall-contract") + + // Load the shipped admin-auth rule so we assert the real where: clause. + rule := loadSonicRule(t, "../../rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml") + + for _, fx := range fixtures { + t.Run(fx.Name, func(t *testing.T) { + normalized := sonicParse(t, cfg, fx.Raw, fx.DataSource, cache) + for path, want := range fx.Expected { + got, ok := sonicGetMust(normalized, path) + if !ok { + t.Fatalf("expected %s to be present", path) + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("%s = %v, want %v", path, got, want) + } + } + for _, path := range fx.Absent { + if got, ok := sonicGetMust(normalized, path); ok { + t.Fatalf("%s should be absent, got %v", path, got) + } + } + if fx.MatchRule != "" { + if fx.MatchRule != "admin_auth_failures" { + t.Fatalf("unknown rule %q", fx.MatchRule) + } + m, e := cache.Eval(rule.Where, normalized) + if e != nil { + t.Fatalf("rule CEL: %v", e) + } + if !m { + t.Fatalf("admin_auth_failures where: did not match: %s", normalized) + } + } + }) + } +} + +func sonicGetMust(normalized, path string) (any, bool) { + var draft map[string]any + if e := json.Unmarshal([]byte(normalized), &draft); e != nil { + panic(e) + } + return sonicGet(draft, path) +} + +func loadSonicRule(t *testing.T, path string) *plugins.Rule { + t.Helper() + b, e := utils.ReadPbYaml(path) + if e != nil { + t.Fatal(e) + } + r := new(plugins.Rule) + if e = protojson.Unmarshal(b, r); e != nil { + t.Fatal(e) + } + r.Normalize() + return r +} diff --git a/plugins/alerts/testdata/sonicwall_admin_auth_raw.json b/plugins/alerts/testdata/sonicwall_admin_auth_raw.json new file mode 100644 index 000000000..4582114ac --- /dev/null +++ b/plugins/alerts/testdata/sonicwall_admin_auth_raw.json @@ -0,0 +1,67 @@ +[ + { + "name": "event32-admin-login-denied-bad-credentials", + "dataSource": "firewall-sonicwall", + "raw": "<166> id=103 sn=SWL-20260924-0001 time=\"Wed Sep 24 02:11:41 2026\" fw=\"SonicWall\" msg=\"Login denied due to bad credentials for admin account\" src=192.0.2.10:4455 usr=\"admin\" eventCode=32", + "expected": { + "origin.ip": "192.0.2.10", + "origin.port": 4455, + "log.eventCode": "32", + "actionResult": "failure" + }, + "absent": ["log.src"], + "matchRule": "admin_auth_failures" + }, + { + "name": "event200-vpn-login-denied", + "dataSource": "firewall-sonicwall", + "raw": "<166> id=104 sn=SWL-20260924-0002 time=\"Wed Sep 24 03:22:09 2026\" fw=\"SonicWall\" msg=\"VPN login denied due to bad credentials\" src=203.0.113.44:1111 usr=\"svc_backup\" eventCode=200", + "expected": { + "origin.ip": "203.0.113.44", + "log.eventCode": "200", + "actionResult": "failure" + }, + "matchRule": "admin_auth_failures" + }, + { + "name": "event33-ssh-login-denied", + "dataSource": "firewall-sonicwall", + "raw": "<166> id=105 sn=SWL-20260924-0003 time=\"Wed Sep 24 04:15:33 2026\" fw=\"SonicWall\" msg=\"CLI login denied due to bad credentials\" src=198.51.100.77:2222 usr=\"netops\" eventCode=33", + "expected": { + "origin.ip": "198.51.100.77", + "log.eventCode": "33", + "actionResult": "failure" + }, + "matchRule": "admin_auth_failures" + }, + { + "name": "event29-login-allowed-is-not-a-failure", + "dataSource": "firewall-sonicwall", + "raw": "<166> id=101 sn=SWL-20260924-0004 time=\"Wed Sep 24 05:01:12 2026\" fw=\"SonicWall\" msg=\"Login allowed for user\" src=192.0.2.80:3333 usr=\"admin\" eventCode=29", + "expected": { + "origin.ip": "192.0.2.80", + "log.eventCode": "29", + "actionResult": "success" + } + }, + { + "name": "event986-policy-denial-is-denied-not-failure", + "dataSource": "firewall-sonicwall", + "raw": "<166> id=110 sn=SWL-20260924-0005 time=\"Wed Sep 24 05:44:00 2026\" fw=\"SonicWall\" msg=\"User login denied - not allowed by Policy rule\" src=192.0.2.81:4444 usr=\"guest\" eventCode=986", + "expected": { + "origin.ip": "192.0.2.81", + "log.eventCode": "986", + "actionResult": "denied" + } + }, + { + "name": "no-source-address-stays-on-vendor-key", + "dataSource": "firewall-sonicwall", + "raw": "<166> id=103 sn=SWL-20260924-0006 time=\"Wed Sep 24 06:10:27 2026\" fw=\"SonicWall\" msg=\"Login denied due to bad credentials\" src=local usr=\"admin\" eventCode=32", + "expected": { + "log.eventCode": "32", + "actionResult": "failure" + }, + "absent": ["origin.ip"] + } +]