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/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"] + } +] diff --git a/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml b/rules/sonicwall/sonicwall_firewall/sonicwall_admin_auth_failures.yml index 1ba922bd0..90666f784 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,9 @@ 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, and raises one + alert per source address for seven days. Next Steps: 1. Investigate the source IP — geolocation, reputation, previous activity. @@ -26,20 +29,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: +deduplicateBy: - adversary.ip - - adversary.user