From 896eda4f7bb66180859b2f4c80f590225254ada5 Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 12:06:47 -0400 Subject: [PATCH 1/7] fix(cisco-switch): keep flaps out of the MAC rule; guard the ARP lookup Names, impact, category, technique, adversary side, references, thresholds and windows are unchanged. The VLAN hopping rule is unchanged. - mac_address_spoofing (v1.0.1): require origin.mac, leave out MAC flap notifications (SW_MATM-4-MACFLAP_NOTIF), read log.msg instead of log.message, and deduplicate by adversary.mac instead of grouping. The filter never wrote origin.mac, so the {{.origin.mac}} history placeholder failed on every flap, and after five failures the CEL plugin disabled the rule with a 'Circuit Breaker' alert; the rule has never produced a detection. The filter change that follows maps origin.mac on every flap. Nothing shows that a flap means an address was copied, and without this change every flap would run a history search. The description now says that flaps are not used. - arp_poisoning_detection (v1.0.1): require origin.ip before the rule can match, and read log.msg instead of log.message. No step writes origin.ip for SW_DAI, IP DUPADDR/SOURCEGUARD or the text branches, so any match would fail its {{.origin.ip}} history search the same way. Co-Authored-By: Claude Opus 5.5 --- .../cs_switch/arp_poisoning_detection.yml | 12 +++++++----- .../cisco/cs_switch/mac_address_spoofing.yml | 19 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/rules/cisco/cs_switch/arp_poisoning_detection.yml b/rules/cisco/cs_switch/arp_poisoning_detection.yml index 04dd22d21..7c20a63c3 100644 --- a/rules/cisco/cs_switch/arp_poisoning_detection.yml +++ b/rules/cisco/cs_switch/arp_poisoning_detection.yml @@ -1,4 +1,4 @@ -# Rule version v1.0.0 +# Rule version v1.0.1 dataTypes: - cisco-switch @@ -26,10 +26,12 @@ description: | 7. Update switch security configurations (enable port security, DHCP snooping, DAI if not already enabled) 8. Consider implementing additional network segmentation to limit attack impact where: | - (equals("log.facility", "SW_DAI") && oneOf("log.facilityMnemonic", ["INVALID_ARP", "DHCP_SNOOPING_DENY", "ACL_DENY"])) - || (equals("log.facility", "IP") && oneOf("log.facilityMnemonic", ["DUPADDR", "SOURCEGUARD"])) - || contains("log.message", ["invalid arp", "arp inspection drop", "dhcp snooping deny", "gratuitous arp", "arp reply not request", "duplicate ip address", "IP source guard deny", "arp packet validation failed"]) - || (lessOrEqual("log.severity", 3) && contains("log.message", ["arp spoofing", "arp poison", "man in the middle"])) + exists("origin.ip") && ( + (equals("log.facility", "SW_DAI") && oneOf("log.facilityMnemonic", ["INVALID_ARP", "DHCP_SNOOPING_DENY", "ACL_DENY"])) + || (equals("log.facility", "IP") && oneOf("log.facilityMnemonic", ["DUPADDR", "SOURCEGUARD"])) + || contains("log.msg", ["invalid arp", "arp inspection drop", "dhcp snooping deny", "gratuitous arp", "arp reply not request", "duplicate ip address", "IP source guard deny", "arp packet validation failed"]) + || (lessOrEqual("log.severity", 3) && contains("log.msg", ["arp spoofing", "arp poison", "man in the middle"])) + ) afterEvents: - indexPattern: v11-log-cisco-switch-* with: diff --git a/rules/cisco/cs_switch/mac_address_spoofing.yml b/rules/cisco/cs_switch/mac_address_spoofing.yml index a887668ad..fe85f933b 100644 --- a/rules/cisco/cs_switch/mac_address_spoofing.yml +++ b/rules/cisco/cs_switch/mac_address_spoofing.yml @@ -1,4 +1,4 @@ -# Rule version v1.0.0 +# Rule version v1.0.1 dataTypes: - cisco-switch @@ -14,22 +14,23 @@ references: - https://www.cisco.com/c/en/us/support/docs/switches/catalyst-3750-series-switches/72846-layer2-secftrs-catl3fixed.html - https://attack.mitre.org/techniques/T1200/ description: | - Detects potential MAC address spoofing attempts by monitoring for MAC address flapping between ports, duplicate MAC addresses, or MAC addresses appearing on unexpected ports. This could indicate an attacker attempting to impersonate legitimate devices. + Detects potential MAC address spoofing attempts from switch messages that report a duplicate MAC address, a MAC address conflict or a dynamic ARP inspection denial and name the MAC address involved. This could indicate an attacker attempting to impersonate legitimate devices. MAC address flap notifications are not used, because a flap alone does not show that an address was copied. Next Steps: - 1. Identify the affected MAC address and ports involved in the flapping + 1. Identify the affected MAC address and the switch ports involved 2. Check if the MAC address belongs to a legitimate device that may be moving between ports 3. Review switch logs for any unauthorized configuration changes 4. Verify if port security or dynamic ARP inspection is properly configured 5. Investigate the source device and check for signs of ARP spoofing tools 6. Consider implementing port security to limit MAC addresses per port 7. Enable DHCP snooping and dynamic ARP inspection if not already configured +# SW_MATM MACFLAP_NOTIF is excluded until its meaning as a spoofing signal is documented. where: | - (equals("log.facility", "SW_MATM") && equals("log.facilityMnemonic", "MACFLAP_NOTIF")) - || (equals("log.facility", "SW_DAI") && oneOf("log.facilityMnemonic", ["INVALID_ARP", "DHCP_SNOOPING_DENY"])) - || regexMatch("log.message", "(?i)(mac.*flap|duplicate.*mac|mac.*move.*between.*port)") - || regexMatch("log.message", "(?i)(Host [0-9a-fA-F:.]+.*is flapping between port)") - || (lessOrEqual("log.severity", 4) && regexMatch("log.message", "(?i)(mac.*address.*conflict|duplicate.*address.*detected)")) + exists("origin.mac") && !regexMatch("log.msg", "(?i)(mac.*flap|is flapping between port)") && ( + (equals("log.facility", "SW_DAI") && oneOf("log.facilityMnemonic", ["INVALID_ARP", "DHCP_SNOOPING_DENY"])) + || regexMatch("log.msg", "(?i)(duplicate.*mac|mac.*move.*between.*port)") + || (lessOrEqual("log.severity", 4) && regexMatch("log.msg", "(?i)(mac.*address.*conflict|duplicate.*address.*detected)")) + ) afterEvents: - indexPattern: v11-log-cisco-switch-* with: @@ -38,5 +39,5 @@ afterEvents: value: '{{.origin.mac}}' within: 10m count: 3 -groupBy: +deduplicateBy: - adversary.mac From 5ee95dc8d58f480bb2a35dcfd7633b9c6a2de363 Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 12:07:22 -0400 Subject: [PATCH 2/7] fix(cisco-switch): helper severity clause; map addresses in six messages Filter 3.1.0. Every change rests on real switch records, the filter's own patterns and the EventProcessor and go-sdk behaviour. Cisco's system message guide could not be read, so nothing that depends on what a message means is changed. - Line 206: write the 'medium' severity condition as equals("log.severity", "4") instead of log.severity=="4". A line without a %FACILITY-SEVERITY-MNEMONIC header has no log object or no log.severity, so the raw comparison failed and the engine stored the error on the event. The helper returns false instead; severity is unchanged on every tested line. - SW_MATM-4-MACFLAP_NOTIF: write the flapping address to origin.mac, the VLAN to log.vlan, and the two interfaces, in the order the message gives them, to log.firstPort and log.secondPort. Interface names stay under log.*; origin.port holds numbers only. - SISF-4-EXCESS_ARP_ACTIVITY: the client address to origin.mac. - SSH-4-SSH2_UNEXPECTED_MSG and SSH-5-SSH_CLOSE: the client address to origin.ip. - DHCPD-4-PING_CONFLICT: the pinged address to target.ip. - SYS-3-LOGGINGHOST_FAIL and SYS-6-LOGGINGHOST_STARTSTOP: the logging host to target.ip and its port to target.port as a number. Each new step runs only for its facility and mnemonic and writes nothing unless the whole text shape matches. The actionResult steps and the severity words are unchanged. Co-Authored-By: Claude Opus 5.5 --- filters/cisco/cs_switch.yml | 94 ++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/filters/cisco/cs_switch.yml b/filters/cisco/cs_switch.yml index bb06b6def..f5c8d3248 100644 --- a/filters/cisco/cs_switch.yml +++ b/filters/cisco/cs_switch.yml @@ -1,4 +1,4 @@ -# CISCO Switch filter, version 3.0.2 +# CISCO Switch filter, version 3.1.0 # Based on https://www.cisco.com/c/en/us/support/ios-nx-os-software/index.html # and https://www.cisco.com/c/en/us/td/docs/ios-xml/ios/17_xe/syslogs/17-15-x/b-system-message-guide-17-15-x.html # Support CISCO IOS from 15 SY to IOS XE v17 @@ -203,7 +203,7 @@ pipeline: params: key: severity value: 'medium' - where: log.severity=="4" + where: equals("log.severity", "4") - add: function: 'string' params: @@ -211,6 +211,96 @@ pipeline: value: 'low' where: oneOf("log.severity", ["5", "6", "7"]) #......................................................................# + # Addresses carried by the message text (shapes observed in real switch records) + # %SW_MATM-4-MACFLAP_NOTIF: Host in vlan is flapping between port and port + # Interface names are text, so they stay under log.*; origin.port holds numbers only. + # The two ports are kept in the order the message gives them (log.firstPort, log.secondPort); + # which of them is the previous port is not established, so the names do not claim a direction. + - grok: + source: log.ciscoMsg + patterns: + - fieldName: "" + pattern: 'Host' + - fieldName: origin.mac + pattern: '{{.ciscoMacAddr}}' + - fieldName: "" + pattern: 'in vlan' + - fieldName: log.vlan + pattern: '{{.integer}}' + - fieldName: "" + pattern: 'is flapping between port' + - fieldName: log.firstPort + pattern: '{{.notSpace}}' + - fieldName: "" + pattern: 'and port' + - fieldName: log.secondPort + pattern: '{{.notSpace}}' + where: equals("log.facility", "SW_MATM") && equals("log.facilityMnemonic", "MACFLAP_NOTIF") + # %SISF-4-EXCESS_ARP_ACTIVITY: ... Excessive ARP activity detected for the client . client is brought down ... + - grok: + source: log.ciscoMsg + patterns: + - fieldName: "" + pattern: '{{.data}}Excessive ARP activity detected for the client' + - fieldName: origin.mac + pattern: '{{.ciscoMacAddr}}' + - fieldName: "" + pattern: '\. client is brought down' + where: equals("log.facility", "SISF") && equals("log.facilityMnemonic", "EXCESS_ARP_ACTIVITY") + # %SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from + - grok: + source: log.ciscoMsg + patterns: + - fieldName: "" + pattern: 'Unexpected message type has arrived\. Terminating the connection from' + - fieldName: origin.ip + pattern: '{{.ipv4}}$' + where: equals("log.facility", "SSH") && equals("log.facilityMnemonic", "SSH2_UNEXPECTED_MSG") + # %SSH-5-SSH_CLOSE: SSH Session from (tty = ) for user '' using crypto cipher '' closed + - grok: + source: log.ciscoMsg + patterns: + - fieldName: "" + pattern: 'SSH Session from' + - fieldName: origin.ip + pattern: '{{.ipv4}}' + - fieldName: "" + pattern: '\(tty' + where: equals("log.facility", "SSH") && equals("log.facilityMnemonic", "SSH_CLOSE") + # %DHCPD-4-PING_CONFLICT: DHCP address conflict: server pinged . + - grok: + source: log.ciscoMsg + patterns: + - fieldName: "" + pattern: 'DHCP address conflict:' + - fieldName: "" + pattern: 'server pinged' + - fieldName: target.ip + pattern: '{{.ipv4}}' + - fieldName: "" + pattern: '\.$' + where: equals("log.facility", "DHCPD") && equals("log.facilityMnemonic", "PING_CONFLICT") + # %SYS-3-LOGGINGHOST_FAIL / %SYS-6-LOGGINGHOST_STARTSTOP: Logging to host port failed|started ... + - grok: + source: log.ciscoMsg + patterns: + - fieldName: "" + pattern: 'Logging to host' + - fieldName: target.ip + pattern: '{{.ipv4}}' + - fieldName: "" + pattern: 'port' + - fieldName: target.port + pattern: '[0-9]{1,5}' + - fieldName: "" + pattern: '(failed|started)' + where: equals("log.facility", "SYS") && oneOf("log.facilityMnemonic", ["LOGGINGHOST_FAIL", "LOGGINGHOST_STARTSTOP"]) + - cast: + fields: + - target.port + to: int + where: equals("log.facility", "SYS") && oneOf("log.facilityMnemonic", ["LOGGINGHOST_FAIL", "LOGGINGHOST_STARTSTOP"]) && exists("target.port") + #......................................................................# # Removing unused fields - delete: fields: From ffd2f959cb632a6be53f08aa3f3af6a67e75f98e Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 12:28:07 -0400 Subject: [PATCH 3/7] test(cisco-switch): add fabricated raw fixtures and regression checks cisco_switch_filter_test.go checks, with go-sdk v1.1.33: that no where clause compares log.* directly, and that every clause evaluates without an error on a draft without a log object, one without a severity and a parsed one; that the severity steps keep their result on every level from 0 to 7; that a model of the engine's step plugins reproduces the playground result for every stored field of 44 fabricated lines, with a positive and a near-miss line for each new mapping; that interface names never reach origin.port or target.port; the unchanged names, metadata, impact and history searches of the three rules, the MAC rule's deduplication and the unchanged VLAN condition; 28 synthetic rule cases; that the MAC and ARP rules match none of the fabricated lines and the VLAN rule exactly the six SW_VLAN/DTP lines; and that a rule with a history search never matches an event that lacks its placeholder fields. All eight tests fail against the original filter and rules. testdata/cisco-switch/replay.py runs the same 44 lines through the public EventProcessor playground with the filter, the three rules and the shared grok definitions, and checks every field and alert. The rules' OpenSearch address is a closed local port, so a history search would fail and be reported. All inputs are invented: MAC addresses in the locally administered 02:00:00:xx:xx:xx range in Cisco's dotted form, RFC 5737 and RFC 3849 addresses, and example names. None is taken from Cisco's documentation, which could not be read. Co-Authored-By: Claude Opus 5.5 --- plugins/alerts/cisco_switch_filter_test.go | 961 ++++++++++++++++++ .../testdata/cisco-switch/expected.json | 614 +++++++++++ .../testdata/cisco-switch/patterns.yaml | 11 + plugins/alerts/testdata/cisco-switch/raw.json | 49 + .../alerts/testdata/cisco-switch/replay.py | 202 ++++ 5 files changed, 1837 insertions(+) create mode 100644 plugins/alerts/cisco_switch_filter_test.go create mode 100644 plugins/alerts/testdata/cisco-switch/expected.json create mode 100644 plugins/alerts/testdata/cisco-switch/patterns.yaml create mode 100644 plugins/alerts/testdata/cisco-switch/raw.json create mode 100644 plugins/alerts/testdata/cisco-switch/replay.py diff --git a/plugins/alerts/cisco_switch_filter_test.go b/plugins/alerts/cisco_switch_filter_test.go new file mode 100644 index 000000000..3398ee71e --- /dev/null +++ b/plugins/alerts/cisco_switch_filter_test.go @@ -0,0 +1,961 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "testing" + "text/template" + "unicode/utf8" + + "github.com/threatwinds/go-sdk/plugins" + "github.com/threatwinds/go-sdk/utils" + "github.com/tidwall/gjson" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// Cisco switch regression checks. Every raw input is FABRICATED (testdata/cisco-switch): Cisco's +// documentation site refused automated access, so the lines copy the text shapes the filter's +// steps read, with invented values: MAC addresses in the locally administered range +// 02:00:00:xx:xx:xx written in Cisco's dotted form, RFC 5737 and RFC 3849 documentation +// addresses, and example host, user and interface names. None is claimed to be a documented +// Cisco format. These tests use the pinned go-sdk v1.1.33 for YAML decoding, CEL and Event +// conversion. They do not run the EventProcessor: cswModel mirrors its step plugins, and +// testdata/cisco-switch/replay.py runs the same lines through the public playground. See +// filters/audits/cisco-switch.md. + +const ( + cswFilter = "../../filters/cisco/cs_switch.yml" + cswRulesDir = "../../rules/cisco/cs_switch" + cswData = "testdata/cisco-switch" + cswTenant = "00000000-0000-4000-8000-000000000001" + cswAbsent = "" +) + +var cswEnvelope = map[string]bool{"id": true, "timestamp": true, "deviceTime": true, "dataType": true, + "dataSource": true, "tenantId": true, "tenantName": true, "raw": true, "errors": true} + +func cswPipeline(t *testing.T) *plugins.Pipeline { + t.Helper() + encoded, err := utils.ReadPbYaml(cswFilter) + if err != nil { + t.Fatal(err) + } + config := new(plugins.Config) + if err := protojson.Unmarshal(encoded, config); err != nil { + t.Fatal(err) + } + if len(config.Pipeline) != 1 || len(config.Pipeline[0].DataTypes) != 1 || + config.Pipeline[0].DataTypes[0] != "cisco-switch" { + t.Fatalf("unexpected pipeline layout: %v", config.Pipeline) + } + return config.Pipeline[0] +} + +// cswWhere returns the where clause of whichever step kind is set. +func cswWhere(step *plugins.Step) string { + where := "" + step.ProtoReflect().Range(func(_ protoreflect.FieldDescriptor, v protoreflect.Value) bool { + msg := v.Message() + if fd := msg.Descriptor().Fields().ByName("where"); fd != nil { + where = msg.Get(fd).String() + } + return false + }) + return where +} + +// Before this revision the 'medium' severity step read log.severity=="4". +var ( + cswRawLog = regexp.MustCompile(`(^|[^"\w.])log\.[A-Za-z0-9_.]+\s*(==|!=|>=|<=|<|>)`) + cswOldMedium = `log.severity=="4"` + cswNoLog = `{"id":"x","dataType":"cisco-switch","dataSource":"fixture-switch","tenantId":"` + cswTenant + `","raw":"x"}` + cswNoSeverity = `{"id":"x","dataType":"cisco-switch","dataSource":"fixture-switch","tenantId":"` + cswTenant + `","raw":"x","log":{"msg":"on /var"}}` + cswParsedDraft = `{"id":"x","dataType":"cisco-switch","dataSource":"fixture-switch","tenantId":"` + cswTenant + `","raw":"x",` + + `"log":{"msg":"SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0101 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42",` + + `"facility":"SW_MATM","severity":"4","facilityMnemonic":"MACFLAP_NOTIF",` + + `"ciscoMsg":"Host 0200.0000.0101 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42"}}` +) + +// No where clause compares a log.* field directly. Such a clause fails, and the engine stores the +// error on the event, when the draft has no log object (a line without any '%') or no +// log.severity (a '%' but no FACILITY-SEVERITY-MNEMONIC header). Every clause evaluates without +// an error on both kinds of draft and on a parsed one. +func TestCiscoSwitchWhereClauses(t *testing.T) { + steps := cswPipeline(t).Steps + cache := plugins.NewCELCache("cisco-switch-where") + drafts := []struct{ name, doc string }{ + {"no log object", cswNoLog}, {"log without severity", cswNoSeverity}, {"parsed flap", cswParsedDraft}, + } + var raw []string + clauses := 0 + for i, step := range steps { + where := cswWhere(step) + if where == "" { + continue + } + clauses++ + if cswRawLog.MatchString(where) { + raw = append(raw, where) + } + for _, d := range drafts { + if _, err := cache.Eval(where, d.doc); err != nil { + t.Errorf("step %d on a draft with %s: %q: %.200v", i, d.name, where, err) + } + } + } + if len(raw) > 0 { + t.Errorf("%d where clauses compare log.* directly and fail without a log object, for example %q", len(raw), raw[0]) + } + if clauses < 23 { + t.Errorf("%d where clauses, want at least 23", clauses) + } + // The raw form fails on exactly those drafts, which is what stored the errors. + for _, d := range drafts[:2] { + if _, err := cache.Eval(cswOldMedium, d.doc); err == nil { + t.Errorf("%s on a draft with %s: expected an error", cswOldMedium, d.name) + } + } +} + +func cswSeveritySteps(t *testing.T) []*plugins.Add { + t.Helper() + var adds []*plugins.Add + for _, step := range cswPipeline(t).Steps { + if a := step.Add; a != nil && a.Params["key"].GetStringValue() == "severity" { + adds = append(adds, a) + } + } + if len(adds) != 3 { + t.Fatalf("%d severity steps, want 3", len(adds)) + } + return adds +} + +// cswSeverity applies the severity steps in filter order; a later match overwrites an earlier one. +func cswSeverity(t *testing.T, cache *plugins.CELCache, adds []*plugins.Add, doc string, medium string) string { + t.Helper() + result := cswAbsent + for _, a := range adds { + where := a.Where + if a.Params["value"].GetStringValue() == "medium" && medium != "" { + where = medium + } + ok, err := cache.Eval(where, doc) + if err != nil { + return "error" + } + if ok { + result = a.Params["value"].GetStringValue() + } + } + return result +} + +// The three severity steps give the same severity as before on every one-digit level and on text +// that is not a number, and no severity and no error without a level. equals and oneOf compare +// numbers, so a level written 04 or +4 now counts as 4, as 03 already counted as 3 and 05 as 5; +// before this revision such a level 4 got no severity. No sampled record has such a level. +func TestCiscoSwitchSeverityClauses(t *testing.T) { + adds := cswSeveritySteps(t) + if got := adds[1].Where; got != `equals("log.severity", "4")` { + t.Fatalf("medium step: %q", got) + } + cache := plugins.NewCELCache("cisco-switch-severity") + doc := func(level string) string { + return fmt.Sprintf(`{"raw":"x","log":{"facility":"FAC","severity":%q,"facilityMnemonic":"MNEM"}}`, level) + } + want := map[string]string{"0": "high", "1": "high", "2": "high", "3": "high", "4": "medium", "5": "low", + "6": "low", "7": "low", "8": cswAbsent, "SP": cswAbsent, "DFC4": cswAbsent, "": cswAbsent, "44": cswAbsent, "4 ": cswAbsent} + for level, w := range want { + got := cswSeverity(t, cache, adds, doc(level), "") + old := cswSeverity(t, cache, adds, doc(level), cswOldMedium) + if got != w || old != w { + t.Errorf("level %q: severity %s, with the old clause %s, want %s", level, got, old, w) + } + } + for level, w := range map[string]string{"03": "high", "04": "medium", "+4": "medium", "05": "low"} { + if got := cswSeverity(t, cache, adds, doc(level), ""); got != w { + t.Errorf("level %q: severity %s, want %s", level, got, w) + } + } + if old := cswSeverity(t, cache, adds, doc("04"), cswOldMedium); old != cswAbsent { + t.Errorf("level \"04\" with the old clause: %s, want no severity", old) + } + for _, d := range []string{cswNoLog, cswNoSeverity} { + if got := cswSeverity(t, cache, adds, d, ""); got != cswAbsent { + t.Errorf("%s: severity %s, want none and no error", d, got) + } + if old := cswSeverity(t, cache, adds, d, cswOldMedium); old != "error" { + t.Errorf("%s: the old clause gave %s, want an error", d, old) + } + } +} + +// cswModel mirrors the ordered step execution of the public EventProcessor at commit +// 497bf53dbd1ae096f7b2dbc7bce77a6bf9f22ce1 (pkg/parsing/parsing.go and plugins/{grok,trim,add, +// cast,delete}/main.go): each where clause is evaluated with the SDK on the whole draft, a failing +// clause is recorded as an error and skips the step, a grok writes nothing unless every pattern +// matched at the start of the remaining text, and every write follows sjson.Set. It is a model +// used to guard the filter in CI, not a substitute for replay.py. +type cswModel struct { + steps []*plugins.Step + defs map[string]string + cache *plugins.CELCache + regex map[string]*regexp.Regexp +} + +func cswNewModel(t *testing.T) *cswModel { + t.Helper() + encoded, err := utils.ReadPbYaml(filepath.Join(cswData, "patterns.yaml")) + if err != nil { + t.Fatal(err) + } + var file struct { + Patterns map[string]string `json:"patterns"` + } + if err := json.Unmarshal(encoded, &file); err != nil { + t.Fatal(err) + } + return &cswModel{steps: cswPipeline(t).Steps, defs: file.Patterns, + cache: plugins.NewCELCache("cisco-switch-model"), regex: map[string]*regexp.Regexp{}} +} + +// compile expands {{.name}} like the SDK regexp cache and compiles the result. +func (m *cswModel) compile(t *testing.T, pattern string) *regexp.Regexp { + t.Helper() + if re, ok := m.regex[pattern]; ok { + return re + } + final := pattern + for i := 0; i < 10 && strings.Contains(final, "{{"); i++ { + parsed, err := template.New("pattern").Option("missingkey=error").Parse(final) + if err != nil { + t.Fatalf("pattern %q: %v", pattern, err) + } + var out bytes.Buffer + if err := parsed.Execute(&out, m.defs); err != nil { + t.Fatalf("pattern %q: %v", pattern, err) + } + if out.String() == final { + break + } + final = out.String() + } + re, err := regexp.Compile(final) + if err != nil { + t.Fatalf("pattern %q: %v", pattern, err) + } + m.regex[pattern] = re + return re +} + +func cswGet(doc map[string]any, path string) (any, bool) { + var cur any = doc + for _, part := range strings.Split(path, ".") { + obj, ok := cur.(map[string]any) + if !ok { + return nil, false + } + if cur, ok = obj[part]; !ok { + return nil, false + } + } + return cur, true +} + +// cswSet follows sjson.Set for plain dotted paths: a missing or scalar parent becomes an object. +func cswSet(doc map[string]any, path string, value any) { + parts := strings.Split(path, ".") + cur := doc + for _, part := range parts[:len(parts)-1] { + next, ok := cur[part].(map[string]any) + if !ok { + next = map[string]any{} + cur[part] = next + } + cur = next + } + cur[parts[len(parts)-1]] = value +} + +func cswDelete(doc map[string]any, path string) { + parts := strings.Split(path, ".") + cur := doc + for _, part := range parts[:len(parts)-1] { + next, ok := cur[part].(map[string]any) + if !ok { + return + } + cur = next + } + delete(cur, parts[len(parts)-1]) +} + +// cswString follows gjson.Result.String for JSON-decoded values. +func cswString(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + case bool: + return strconv.FormatBool(x) + case nil: + return "" + default: + b, _ := json.Marshal(x) + return string(b) + } +} + +func (m *cswModel) run(t *testing.T, raw string) (map[string]any, []string) { + t.Helper() + doc := map[string]any{"id": "fixture", "dataType": "cisco-switch", "dataSource": "fixture-switch", + "@timestamp": "2026-09-24T14:00:00Z", "tenantId": cswTenant, "raw": raw} + var errs []string + for i, step := range m.steps { + if where := cswWhere(step); where != "" { + draft, _ := json.Marshal(doc) + ok, err := m.cache.Eval(where, string(draft)) + if err != nil { + errs = append(errs, err.Error()) + } + if !ok { + continue + } + } + var err error + switch { + case step.Grok != nil: + err = m.grok(t, doc, step.Grok) + case step.Trim != nil: + err = m.trim(t, doc, step.Trim) + case step.Add != nil: + key := step.Add.Params["key"].GetStringValue() + utils.SanitizeField(&key) + if err = utils.ValidateReservedField(key, false); err == nil && step.Add.Function == "string" { + cswSet(doc, key, step.Add.Params["value"].GetStringValue()) + } else if err == nil { + err = fmt.Errorf("add function %q not modelled", step.Add.Function) + } + case step.Cast != nil: + if step.Cast.To != "int" { + t.Fatalf("step %d: cast to %s not modelled", i, step.Cast.To) + } + for _, f := range step.Cast.Fields { + if v, ok := cswGet(doc, f); ok { + cswSet(doc, f, float64(utils.CastInt64(v))) + } + } + case step.Delete != nil: + for _, f := range step.Delete.Fields { + cswDelete(doc, f) + } + default: + t.Fatalf("step %d: kind not modelled", i) + } + if err != nil { + errs = append(errs, err.Error()) + } + } + return doc, errs +} + +func (m *cswModel) grok(t *testing.T, doc map[string]any, g *plugins.Grok) error { + source := "raw" + if g.Source != "" { + source = g.Source + } + v, ok := cswGet(doc, source) + if !ok { + return nil + } + value := cswString(v) + type capture struct{ field, value string } + var store []capture + size := 0 + for _, p := range g.Patterns { + value = strings.TrimSpace(value) + if utf8.RuneCountInString(value) == 0 { + break + } + match := m.compile(t, p.Pattern).FindString(value) + if match == "" || !strings.HasPrefix(value, match) { + break + } + field := p.FieldName + utils.SanitizeField(&field) + if err := utils.ValidateReservedField(field, true); err != nil { + return err + } + size++ + if field != "" { + store = append(store, capture{field, strings.TrimSpace(match)}) + } + value = strings.TrimPrefix(value, match) + } + if size == len(g.Patterns) { + for _, c := range store { + cswSet(doc, c.field, c.value) + } + } + return nil +} + +func (m *cswModel) trim(t *testing.T, doc map[string]any, tr *plugins.Trim) error { + for _, f := range tr.Fields { + if err := utils.ValidateReservedField(f, false); err != nil { + return err + } + v, ok := cswGet(doc, f) + if !ok || cswString(v) == "" { + continue + } + s := strings.TrimSpace(cswString(v)) + switch tr.Function { + case "prefix": + s = strings.TrimPrefix(s, tr.Substring) + case "suffix": + s = strings.TrimSuffix(s, tr.Substring) + case "substring": + s = strings.ReplaceAll(s, tr.Substring, "") + default: + t.Fatalf("trim function %s not modelled", tr.Function) + } + cswSet(doc, f, strings.TrimSpace(s)) + } + return nil +} + +// cswFinalize converts the draft to the SDK Event and back to JSON the way the playground's event +// writer stores it. +func cswFinalize(t *testing.T, doc map[string]any) (*plugins.Event, map[string]any) { + t.Helper() + b, _ := json.Marshal(doc) + draft := string(b) + event := new(plugins.Event) + if err := utils.StringToProtoMessage(&draft, event); err != nil { + t.Fatal(err) + } + out, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + var stored map[string]any + if err := json.Unmarshal(out, &stored); err != nil { + t.Fatal(err) + } + return event, stored +} + +// cswFields flattens an event to dotted leaf paths, keeping empty objects as leaves. +func cswFields(event map[string]any) map[string]any { + out := map[string]any{} + var walk func(v any, prefix string) + walk = func(v any, prefix string) { + if obj, ok := v.(map[string]any); ok && (len(obj) > 0 || prefix == "") { + for k, x := range obj { + if prefix == "" && cswEnvelope[k] { + continue + } + p := k + if prefix != "" { + p = prefix + "." + k + } + walk(x, p) + } + return + } + out[prefix] = v + } + walk(event, "") + return out +} + +type cswCase struct { + LogObject bool `json:"logObject"` + Fields map[string]any `json:"fields"` + Alerts []string `json:"alerts"` +} + +func cswFixtures(t *testing.T) (map[string]string, map[string]cswCase) { + t.Helper() + var raw struct { + Cases map[string]string `json:"cases"` + } + var expected struct { + Cases map[string]cswCase `json:"cases"` + } + for name, target := range map[string]any{"raw.json": &raw, "expected.json": &expected} { + data, err := os.ReadFile(filepath.Join(cswData, name)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, target); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + if len(raw.Cases) == 0 || len(raw.Cases) != len(expected.Cases) { + t.Fatalf("raw fixtures %d, expectations %d", len(raw.Cases), len(expected.Cases)) + } + return raw.Cases, expected.Cases +} + +// cswModelEvents runs every fabricated line through the model and finalizes it. +func cswModelEvents(t *testing.T) (map[string]*plugins.Event, map[string]map[string]any, map[string][]string) { + t.Helper() + model := cswNewModel(t) + raw, _ := cswFixtures(t) + events, stored, errs := map[string]*plugins.Event{}, map[string]map[string]any{}, map[string][]string{} + for name, line := range raw { + doc, e := model.run(t, line) + events[name], stored[name] = cswFinalize(t, doc) + errs[name] = e + } + return events, stored, errs +} + +// Each change, with positive and near-miss lines. A nil value means the path must be absent. +var cswChangeCases = []struct { + change, fixture, path string + want any +}{ + {"F-1", "unparsed-no-percent", "log", nil}, + {"F-1", "unparsed-no-percent", "severity", nil}, + {"F-1", "unparsed-percent-without-header", "log.msg", "on /var"}, + {"F-1", "unparsed-percent-without-header", "log.severity", nil}, + {"F-1", "unparsed-percent-without-header", "severity", nil}, + {"F-1", "header-seq-ms", "severity", "medium"}, + {"F-1 control", "severity-3-link", "severity", "high"}, + {"F-1 control", "severity-5-lineproto", "severity", "low"}, + {"F-1 control", "severity-5-subfacility", "log.subFacility", "SP"}, + {"F-1 control", "severity-5-subfacility", "severity", "low"}, + {"F-1 control", "misrouted-firepower-shape", "log.facility", "FTD"}, + {"F-1 control", "misrouted-firepower-shape", "severity", "high"}, + {"F-2", "header-seq-ms", "origin.mac", "0200.0000.0101"}, + {"F-2", "header-seq-ms", "log.vlan", "910"}, + {"F-2", "header-seq-ms", "log.firstPort", "Gi9/0/41"}, + {"F-2", "header-seq-ms", "log.secondPort", "Gi9/0/42"}, + {"F-2", "header-star-year", "origin.mac", "0200.0000.0102"}, + {"F-2", "header-star-year", "log.firstPort", "Te9/1/2"}, + {"F-2", "header-star-year", "log.secondPort", "Te9/1/1"}, + {"F-2", "header-dot", "origin.mac", "0200.0000.0103"}, + {"F-2", "flap-upper-hex-port-channel", "origin.mac", "0200.00AB.CD01"}, + {"F-2", "flap-upper-hex-port-channel", "log.vlan", "930"}, + {"F-2", "flap-upper-hex-port-channel", "log.firstPort", "Po9"}, + {"F-2", "flap-upper-hex-port-channel", "log.secondPort", "Gi9/0/43"}, + {"F-2", "flap-trailing-text", "log.secondPort", "Gi9/0/42"}, + {"F-2", "flap-slot-branch", "origin.mac", "0200.0000.0105"}, + {"F-2", "flap-slot-branch", "log.slot", "SLOT3"}, + {"F-2 near miss", "flap-colon-mac", "origin.mac", nil}, + {"F-2 near miss", "flap-colon-mac", "log.vlan", nil}, + {"F-2 near miss", "flap-colon-mac", "log.firstPort", nil}, + {"F-2 near miss", "flap-thirteen-hex", "origin.mac", nil}, + {"F-2 near miss", "flap-no-vlan", "origin.mac", nil}, + {"F-2 near miss", "flap-truncated", "origin.mac", nil}, + {"F-2 near miss", "flap-truncated", "log.firstPort", nil}, + {"F-2 near miss", "flap-under-sw-vlan", "origin.mac", nil}, + {"F-3", "sisf-excess-arp", "origin.mac", "0200.0000.0201"}, + {"F-3", "sisf-no-prefix", "origin.mac", "0200.0000.0202"}, + {"F-3 near miss", "sisf-colon-mac", "origin.mac", nil}, + {"F-4", "ssh2-unexpected", "origin.ip", "198.51.100.21"}, + {"F-4", "ssh-close", "origin.ip", "198.51.100.23"}, + {"F-4 near miss", "ssh2-unexpected-ipv6", "origin.ip", nil}, + {"F-4 near miss", "ssh2-unexpected-bad-octet", "origin.ip", nil}, + {"F-4 near miss", "ssh2-unexpected-trailing", "origin.ip", nil}, + {"F-5", "dhcpd-ping-conflict", "target.ip", "192.0.2.31"}, + {"F-5 near miss", "dhcpd-no-period", "target.ip", nil}, + {"F-5 near miss", "dhcpd-bad-octet", "target.ip", nil}, + {"F-6", "logginghost-fail", "target.ip", "192.0.2.41"}, + {"F-6", "logginghost-fail", "target.port", float64(514)}, + {"F-6", "logginghost-started", "target.ip", "192.0.2.42"}, + {"F-6", "logginghost-started", "target.port", float64(6514)}, + {"F-6 near miss", "logginghost-stopped", "target.ip", nil}, + {"F-6 near miss", "logginghost-host-name", "target.ip", nil}, + {"F-6 near miss", "logginghost-port-text", "target.ip", nil}, + {"F-6 near miss", "logginghost-port-text", "target.port", nil}, + {"F-6 near miss", "logginghost-port-eleven-digits", "target.port", nil}, + {"unchanged", "dai-invalid-arp", "actionResult", "blocked"}, + {"unchanged", "dai-invalid-arp", "origin.mac", nil}, + {"unchanged", "dai-invalid-arp", "origin.ip", nil}, + {"unchanged", "dai-dhcp-snooping-deny", "actionResult", "blocked"}, + {"unchanged", "ip-dupaddr", "origin.ip", nil}, + {"unchanged", "mac-duplicate-text", "origin.mac", nil}, +} + +// Every fabricated line through the model: the named change cases, no where errors, and every +// stored field equal to the playground result recorded in expected.json. +func TestCiscoSwitchExtractionModel(t *testing.T) { + _, stored, errs := cswModelEvents(t) + _, expected := cswFixtures(t) + for _, c := range cswChangeCases { + var got any = cswAbsent + if v, ok := cswGet(stored[c.fixture], c.path); ok { + got = v + } + want := c.want + if want == nil { + want = cswAbsent + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s %s: %s = %v, want %v", c.change, c.fixture, c.path, got, want) + } + } + for name, want := range expected { + if len(errs[name]) > 0 { + t.Errorf("%s: %d where errors, first: %.200s", name, len(errs[name]), errs[name][0]) + } + if _, ok := stored[name]["log"]; ok != want.LogObject { + t.Errorf("%s: log object present=%t", name, ok) + } + got := cswFields(stored[name]) + keys := map[string]bool{} + for k := range got { + keys[k] = true + } + for k := range want.Fields { + keys[k] = true + } + for k := range keys { + g, gok := got[k] + w, wok := want.Fields[k] + if gok != wok || !reflect.DeepEqual(g, w) { + t.Errorf("%s: %s = %v (present %t), want %v (present %t)", name, k, g, gok, w, wok) + } + } + } +} + +// Interface names are text. origin.port and target.port are whole numbers (uint32) in the SDK +// Event, and an interface name there fails the conversion of the whole event, so the flap +// interfaces go to log.firstPort and log.secondPort. Only the logging-host step writes a port, +// with a digits-only pattern, and a cast to int under the same condition follows it. +func TestCiscoSwitchPortFields(t *testing.T) { + steps := cswPipeline(t).Steps + writers := 0 + for i, step := range steps { + g := step.Grok + if g == nil { + continue + } + for _, p := range g.Patterns { + switch p.FieldName { + case "origin.port": + t.Errorf("step %d writes origin.port", i) + case "target.port": + writers++ + if p.Pattern != "[0-9]{1,5}" { + t.Errorf("step %d writes target.port from %q, want digits only", i, p.Pattern) + } + next := &plugins.Cast{} + if i+1 < len(steps) && steps[i+1].Cast != nil { + next = steps[i+1].Cast + } + if next.To != "int" || len(next.Fields) != 1 || next.Fields[0] != "target.port" || + next.Where != g.Where+` && exists("target.port")` { + t.Errorf("step %d: target.port is not cast to int under the same condition: %v", i, next) + } + case "log.firstPort", "log.secondPort": + if p.Pattern != "{{.notSpace}}" { + t.Errorf("step %d: %s from %q", i, p.FieldName, p.Pattern) + } + } + } + } + if writers != 1 { + t.Errorf("%d steps write target.port, want 1", writers) + } + _, stored, _ := cswModelEvents(t) + flaps := 0 + for name, e := range stored { + if v, ok := cswGet(e, "origin.port"); ok { + t.Errorf("%s: origin.port = %v", name, v) + } + if v, ok := cswGet(e, "target.port"); ok { + if _, number := v.(float64); !number { + t.Errorf("%s: target.port = %v (%T), want a number", name, v, v) + } + } + if _, ok := cswGet(e, "log.firstPort"); ok { + flaps++ + if _, ok := cswGet(e, "log.secondPort"); !ok { + t.Errorf("%s: log.firstPort without log.secondPort", name) + } + } + } + if flaps != 6 { + t.Errorf("%d fabricated lines carry the flap interfaces, want 6", flaps) + } + for _, bad := range []string{`{"origin":{"port":"Gi9/0/41"}}`, `{"target":{"port":"Po9"}}`} { + draft := `{"dataType":"cisco-switch","raw":"x",` + strings.TrimPrefix(bad, "{") + if err := utils.StringToProtoMessage(&draft, new(plugins.Event)); err == nil { + t.Errorf("%s converted without an error", bad) + } + } +} + +func cswLoadRules(t *testing.T) map[string]*plugins.Rule { + t.Helper() + files, err := filepath.Glob(filepath.Join(cswRulesDir, "*.y*ml")) + if err != nil || len(files) != 3 { + t.Fatalf("Cisco switch rules: %d files, error %v", len(files), err) + } + out := map[string]*plugins.Rule{} + for _, path := range files { + encoded, err := utils.ReadPbYaml(path) + if err != nil { + t.Fatal(err) + } + rule := new(plugins.Rule) + if err := protojson.Unmarshal(encoded, rule); err != nil { + t.Fatalf("%s: %v", path, err) + } + rule.Normalize() + out[strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))] = rule + } + return out +} + +func cswSearches(searches []*plugins.SearchRequest) string { + var parts []string + for _, s := range searches { + var with []string + for _, e := range s.With { + with = append(with, e.Field+" "+e.Operator+" "+e.Value.GetStringValue()) + } + p := fmt.Sprintf("%s[%s] within %s count %d", s.IndexPattern, strings.Join(with, "; "), s.Within, s.Count) + if len(s.Or) > 0 { + p += " or(" + cswSearches(s.Or) + ")" + } + parts = append(parts, p) + } + return strings.Join(parts, " | ") +} + +func cswPlaceholders(searches []*plugins.SearchRequest, out map[string]bool) { + for _, s := range searches { + for _, e := range s.With { + if v := e.Value.GetStringValue(); strings.HasPrefix(v, "{{.") && strings.HasSuffix(v, "}}") { + out[strings.TrimSuffix(strings.TrimPrefix(v, "{{."), "}}")] = true + } + } + cswPlaceholders(s.Or, out) + } +} + +// The VLAN hopping condition of v11 4a000bc4, which this revision leaves as it is. +const cswVlanWhere = `(equals("log.facility", "SW_VLAN") && oneOf("log.facilityMnemonic", ["VLAN_INCONSISTENCY", "MACFLAP_NOTIF", "TRUNK_MODE_CHANGE"])) +|| (equals("log.facility", "DTP") && oneOf("log.facilityMnemonic", ["NONTRUNKPORTON", "DOMAINMISMATCH", "TRUNKPORTON"])) +|| regexMatch("log.message", "(?i)(received 802.1Q BPDU on non trunk|native vlan mismatch|inconsistent vlan|double tag)") +|| (lessOrEqual("log.severity", 4) && regexMatch("log.message", "(?i)(vlan.*tag.*tag|switch.*spoofing|dtp.*negotiation)"))` + +// Names, metadata, impact, adversary side and history searches stay as they were. The MAC rule +// deduplicates by adversary.mac instead of grouping by it; the VLAN rule is unchanged, condition +// included. +func TestCiscoSwitchRuleContract(t *testing.T) { + const index = "v11-log-cisco-switch-*" + const cisco3750 = "https://www.cisco.com/c/en/us/support/docs/switches/catalyst-3750-series-switches/72846-layer2-secftrs-catl3fixed.html" + want := map[string]string{ + "arp_poisoning_detection": "ARP Poisoning Attack Detection|Credential Access, Collection|T1557.002 - Adversary-in-the-Middle: ARP Cache Poisoning|origin|3/3/2|" + + "groupBy=adversary.ip,adversary.mac|deduplicateBy=|" + + "https://www.cisco.com/c/en/us/td/docs/switches/lan/catalyst4500/12-2/25ew/configuration/guide/conf/dynarp.html,https://attack.mitre.org/techniques/T1557/002/|" + + index + "[origin.ip filter_term {{.origin.ip}}] within 10m count 5", + "mac_address_spoofing": "MAC Address Spoofing Detection|Initial Access|MAC Spoofing|origin|2/3/1|" + + "groupBy=|deduplicateBy=adversary.mac|" + cisco3750 + ",https://attack.mitre.org/techniques/T1200/|" + + index + "[origin.mac filter_term {{.origin.mac}}] within 10m count 3", + "vlan_hopping_attempts": "VLAN Hopping Attack Detection|Defense Evasion|T1599 - Network Boundary Bridging|origin|3/3/2|" + + "groupBy=adversary.ip,adversary.mac|deduplicateBy=|" + cisco3750 + ",https://attack.mitre.org/techniques/T1599/|", + } + rules := cswLoadRules(t) + for stem, rule := range rules { + got := fmt.Sprintf("%s|%s|%s|%s|%d/%d/%d|groupBy=%s|deduplicateBy=%s|%s|%s", rule.Name, rule.Category, rule.Technique, + rule.Adversary, rule.Impact.Confidentiality, rule.Impact.Integrity, rule.Impact.Availability, + strings.Join(rule.GroupBy, ","), strings.Join(rule.DeduplicateBy, ","), strings.Join(rule.References, ","), + cswSearches(rule.Correlation)) + if got != want[stem] { + t.Errorf("%s:\n got %s\nwant %s", stem, got, want[stem]) + } + if len(rule.DataTypes) != 1 || rule.DataTypes[0] != "cisco-switch" { + t.Errorf("%s: dataTypes %v", stem, rule.DataTypes) + } + } + if got := strings.TrimSpace(rules["vlan_hopping_attempts"].Where); got != cswVlanWhere { + t.Errorf("VLAN hopping condition changed:\n%s", got) + } + mac := strings.TrimSpace(rules["mac_address_spoofing"].Where) + if !strings.HasPrefix(mac, `exists("origin.mac") && !regexMatch("log.msg", "(?i)(mac.*flap|is flapping between port)") && (`) || + !strings.HasSuffix(mac, ")") || strings.Contains(mac, "log.message") || strings.Contains(mac, "SW_MATM") { + t.Errorf("MAC condition must require origin.mac, leave out flaps and read log.msg: %s", mac) + } + arp := strings.TrimSpace(rules["arp_poisoning_detection"].Where) + if !strings.HasPrefix(arp, `exists("origin.ip") && (`) || !strings.HasSuffix(arp, ")") || strings.Contains(arp, "log.message") { + t.Errorf("ARP condition must require origin.ip and read log.msg: %s", arp) + } +} + +func cswEvent(t *testing.T, body string) *plugins.Event { + t.Helper() + input := `{"dataType":"cisco-switch","dataSource":"fixture-switch","tenantId":"` + cswTenant + `",` + body + `}` + event := new(plugins.Event) + if err := utils.StringToProtoMessage(&input, event); err != nil { + t.Fatalf("%s: %v", body, err) + } + return event +} + +const ( + cswFlapText = "Host 0200.0000.0101 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42" + cswFlapBody = `"log":{"facility":"SW_MATM","facilityMnemonic":"MACFLAP_NOTIF","severity":"4",` + + `"msg":"SW_MATM-4-MACFLAP_NOTIF: ` + cswFlapText + `","ciscoMsg":"` + cswFlapText + `",` + + `"vlan":"910","firstPort":"Gi9/0/41","secondPort":"Gi9/0/42"},"origin":{"mac":"0200.0000.0101"}` + cswDaiBody = `"log":{"facility":"SW_DAI","facilityMnemonic":"INVALID_ARP","severity":"4","msg":"SW_DAI-4-INVALID_ARP: 1 Invalid ARPs (Req) on Gi9/0/44, vlan 910."},"actionResult":"blocked"` +) + +// Synthetic normalized events. The wording of every text that is not a flap, SISF or SSH message is +// invented: the MAC and ARP rules' positive branches need addresses this filter does not map yet. +var cswRuleCases = []struct { + rule, name, body string + want bool +}{ + {"mac_address_spoofing", "flap as this filter stores it", cswFlapBody, false}, + {"mac_address_spoofing", "flap wording under another mnemonic, with an address", `"log":{"facility":"EXAMPLE","facilityMnemonic":"MAC_FLAP","severity":"2","msg":"EXAMPLE-2-MAC_FLAP: duplicate mac 0200.0000.0401 is flapping between port Gi9/0/41 and port Gi9/0/42"},"origin":{"mac":"0200.0000.0401"}`, false}, + {"mac_address_spoofing", "duplicate MAC text with the address", `"log":{"facility":"EXAMPLE","facilityMnemonic":"DUP_MAC","severity":"4","msg":"EXAMPLE-4-DUP_MAC: Duplicate MAC address 0200.0000.0402 detected"},"origin":{"mac":"0200.0000.0402"}`, true}, + {"mac_address_spoofing", "duplicate MAC text without an address", `"log":{"facility":"EXAMPLE","facilityMnemonic":"DUP_MAC","severity":"4","msg":"EXAMPLE-4-DUP_MAC: Duplicate MAC address 0200.0000.0402 detected"}`, false}, + {"mac_address_spoofing", "duplicate MAC text only in log.message", `"log":{"facility":"EXAMPLE","facilityMnemonic":"DUP_MAC","severity":"4","message":"Duplicate MAC address 0200.0000.0402 detected"},"origin":{"mac":"0200.0000.0402"}`, false}, + {"mac_address_spoofing", "MAC move text with the address", `"log":{"facility":"EXAMPLE","facilityMnemonic":"MAC_MOVE","severity":"5","msg":"EXAMPLE-5-MAC_MOVE: MAC 0200.0000.0403 moved between port Gi9/0/41 and port Gi9/0/42"},"origin":{"mac":"0200.0000.0403"}`, true}, + {"mac_address_spoofing", "MAC conflict text at level 3 with the address", `"log":{"facility":"EXAMPLE","facilityMnemonic":"CONFLICT","severity":"3","msg":"EXAMPLE-3-CONFLICT: MAC address conflict for 0200.0000.0404"},"origin":{"mac":"0200.0000.0404"}`, true}, + {"mac_address_spoofing", "MAC conflict text at level 5 with the address", `"log":{"facility":"EXAMPLE","facilityMnemonic":"CONFLICT","severity":"5","msg":"EXAMPLE-5-CONFLICT: MAC address conflict for 0200.0000.0404"},"origin":{"mac":"0200.0000.0404"}`, false}, + {"mac_address_spoofing", "SW_DAI with an address (needs a future mapping, D-3)", cswDaiBody + `,"origin":{"mac":"0200.0000.0405"}`, true}, + {"mac_address_spoofing", "SW_DAI as this filter stores it", cswDaiBody, false}, + {"mac_address_spoofing", "SISF as this filter stores it", `"log":{"facility":"SISF","facilityMnemonic":"EXCESS_ARP_ACTIVITY","severity":"4","msg":"SISF-4-EXCESS_ARP_ACTIVITY: Excessive ARP activity detected for the client 0200.0000.0201. client is brought down and added to the exclusion list"},"origin":{"mac":"0200.0000.0201"}`, false}, + {"arp_poisoning_detection", "SW_DAI with a source address (needs a future mapping, D-3)", cswDaiBody + `,"origin":{"ip":"192.0.2.51"}`, true}, + {"arp_poisoning_detection", "SW_DAI as this filter stores it", cswDaiBody, false}, + {"arp_poisoning_detection", "IP DUPADDR with an address", `"log":{"facility":"IP","facilityMnemonic":"DUPADDR","severity":"4","msg":"IP-4-DUPADDR: Duplicate address 192.0.2.54 on Vlan910, sourced by 0200.0000.0304"},"origin":{"ip":"192.0.2.54"}`, true}, + {"arp_poisoning_detection", "IP SOURCEGUARD as this filter stores it", `"log":{"facility":"IP","facilityMnemonic":"SOURCEGUARD","severity":"4","msg":"IP-4-SOURCEGUARD: IP source guard deny on Gi9/0/44 vlan 910 for 192.0.2.55"}`, false}, + {"arp_poisoning_detection", "ARP phrase in log.msg with an address", `"log":{"facility":"EXAMPLE","facilityMnemonic":"ARP","severity":"4","msg":"EXAMPLE-4-ARP: gratuitous arp received from 192.0.2.56"},"origin":{"ip":"192.0.2.56"}`, true}, + {"arp_poisoning_detection", "ARP phrase only in log.message", `"log":{"facility":"EXAMPLE","facilityMnemonic":"ARP","severity":"4","message":"gratuitous arp received from 192.0.2.56"},"origin":{"ip":"192.0.2.56"}`, false}, + {"arp_poisoning_detection", "ARP phrase with capitals (contains is case-sensitive, D-3)", `"log":{"facility":"EXAMPLE","facilityMnemonic":"ARP","severity":"4","msg":"EXAMPLE-4-ARP: Gratuitous ARP received from 192.0.2.56"},"origin":{"ip":"192.0.2.56"}`, false}, + {"arp_poisoning_detection", "spoofing phrase at level 3", `"log":{"facility":"EXAMPLE","facilityMnemonic":"ARP","severity":"3","msg":"EXAMPLE-3-ARP: possible arp spoofing from 192.0.2.57"},"origin":{"ip":"192.0.2.57"}`, true}, + {"arp_poisoning_detection", "spoofing phrase at level 5", `"log":{"facility":"EXAMPLE","facilityMnemonic":"ARP","severity":"5","msg":"EXAMPLE-5-ARP: possible arp spoofing from 192.0.2.57"},"origin":{"ip":"192.0.2.57"}`, false}, + {"arp_poisoning_detection", "SSH as this filter stores it", `"log":{"facility":"SSH","facilityMnemonic":"SSH2_UNEXPECTED_MSG","severity":"4","msg":"SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 198.51.100.21"},"origin":{"ip":"198.51.100.21"}`, false}, + {"vlan_hopping_attempts", "SW_VLAN VLAN_INCONSISTENCY", `"log":{"facility":"SW_VLAN","facilityMnemonic":"VLAN_INCONSISTENCY","severity":"4"}`, true}, + {"vlan_hopping_attempts", "DTP TRUNKPORTON", `"log":{"facility":"DTP","facilityMnemonic":"TRUNKPORTON","severity":"5"}`, true}, + {"vlan_hopping_attempts", "flap text under SW_VLAN", `"log":{"facility":"SW_VLAN","facilityMnemonic":"MACFLAP_NOTIF","severity":"4"}`, true}, + {"vlan_hopping_attempts", "flap as this filter stores it", cswFlapBody, false}, + {"vlan_hopping_attempts", "SW_VLAN mnemonic not listed", `"log":{"facility":"SW_VLAN","facilityMnemonic":"VTPMODECHANGE","severity":"6"}`, false}, + {"vlan_hopping_attempts", "text branch in log.message (unchanged; nothing writes it, D-4)", `"log":{"facility":"CDP","facilityMnemonic":"NATIVE_VLAN_MISMATCH","severity":"4","message":"Native VLAN mismatch discovered"}`, true}, + {"vlan_hopping_attempts", "same text in log.msg", `"log":{"facility":"CDP","facilityMnemonic":"NATIVE_VLAN_MISMATCH","severity":"4","msg":"CDP-4-NATIVE_VLAN_MISMATCH: Native VLAN mismatch discovered"}`, false}, +} + +// SDK v1.1.33 CEL on synthetic normalized events for the three rule conditions. +func TestCiscoSwitchRulePredicates(t *testing.T) { + rules := cswLoadRules(t) + cache := plugins.NewCELCache("cisco-switch-rules") + for _, c := range cswRuleCases { + t.Run(c.rule+"/"+c.name, func(t *testing.T) { + got, err := cache.Eval(rules[c.rule].Where, cswEvent(t, c.body)) + if err != nil { + t.Fatal(err) + } + if got != c.want { + t.Fatalf("match=%t want=%t", got, c.want) + } + }) + } +} + +// The rule conditions on the model output of every fabricated line: the MAC and ARP rules match +// none (no line reaches a history search), and the VLAN rule matches exactly the lines whose +// playground alerts expected.json records. +func TestCiscoSwitchRulesOnFabricatedLines(t *testing.T) { + rules := cswLoadRules(t) + cache := plugins.NewCELCache("cisco-switch-lines") + events, _, _ := cswModelEvents(t) + _, expected := cswFixtures(t) + for name, event := range events { + var matched []string + for stem, rule := range rules { + ok, err := cache.Eval(rule.Where, event) + if err != nil { + t.Fatalf("%s on %s: %v", stem, name, err) + } + if ok { + matched = append(matched, stem) + } + } + sort.Strings(matched) + want := append([]string{}, expected[name].Alerts...) + sort.Strings(want) + if strings.Join(matched, ",") != strings.Join(want, ",") { + t.Errorf("%s: conditions match %v, playground alerts %v", name, matched, want) + } + } + vlan := 0 + for _, c := range expected { + for _, a := range c.Alerts { + if a != "vlan_hopping_attempts" { + t.Errorf("unexpected expected alert %s", a) + } + vlan++ + } + } + if vlan != 6 { + t.Errorf("%d VLAN hopping alerts expected, want 6", vlan) + } +} + +// Whenever a rule with a history search matches, every {{.field}} placeholder resolves; an +// unresolved one fails the search, and five failures disable the rule with a Circuit Breaker +// alert. Checked on the model output of every fabricated line and on the synthetic events. +func TestCiscoSwitchHistoryPlaceholders(t *testing.T) { + rules := cswLoadRules(t) + cache := plugins.NewCELCache("cisco-switch-history") + events, _, _ := cswModelEvents(t) + for _, c := range cswRuleCases { + events["synthetic: "+c.name] = cswEvent(t, c.body) + } + names := make([]string, 0, len(events)) + for name := range events { + names = append(names, name) + } + sort.Strings(names) + checked := 0 + for stem, rule := range rules { + fields := map[string]bool{} + cswPlaceholders(rule.Correlation, fields) + if len(fields) == 0 { + continue + } + for _, name := range names { + match, err := cache.Eval(rule.Where, events[name]) + if err != nil { + t.Fatalf("%s on %s: %v", stem, name, err) + } + if !match { + continue + } + checked++ + doc, err := utils.ProtoMessageToString(events[name]) + if err != nil { + t.Fatal(err) + } + for field := range fields { + if gjson.Get(*doc, field).Value() == nil { + t.Errorf("%s matches %s without %s; its history search would fail", stem, name, field) + } + } + } + } + if checked < 8 { + t.Errorf("%d positive cases reached a history search, want at least 8", checked) + } +} diff --git a/plugins/alerts/testdata/cisco-switch/expected.json b/plugins/alerts/testdata/cisco-switch/expected.json new file mode 100644 index 000000000..e7d4c478f --- /dev/null +++ b/plugins/alerts/testdata/cisco-switch/expected.json @@ -0,0 +1,614 @@ +{ + "provenance": "Expected results for the FABRICATED lines in raw.json, recorded from the public EventProcessor playground (commit 497bf53dbd1ae096f7b2dbc7bce77a6bf9f22ce1) with this filter, the three rules and patterns.yaml, after the declared per-change expectations were checked. fields excludes the envelope keys, deviceTime and tenantName.", + "envelopeTimestamp": "2026-09-24T14:00:00Z", + "cases": { + "dai-dhcp-snooping-deny": { + "logObject": true, + "fields": { + "log.ciscoMsg": "1 Invalid ARPs (Res) on Gi9/0/44, vlan 910.([0200.0000.0302/192.0.2.52/0200.0000.0303/192.0.2.53/13:11:01 UTC Thu Sep 24 2026])", + "log.ciscoTime": "Sep 24 13:11:01.500", + "log.facility": "SW_DAI", + "log.facilityMnemonic": "DHCP_SNOOPING_DENY", + "log.msg": "SW_DAI-4-DHCP_SNOOPING_DENY: 1 Invalid ARPs (Res) on Gi9/0/44, vlan 910.([0200.0000.0302/192.0.2.52/0200.0000.0303/192.0.2.53/13:11:01 UTC Thu Sep 24 2026])", + "log.severity": "4", + "actionResult": "blocked", + "severity": "medium" + }, + "alerts": [] + }, + "dai-invalid-arp": { + "logObject": true, + "fields": { + "log.ciscoMsg": "1 Invalid ARPs (Req) on Gi9/0/44, vlan 910.([0200.0000.0301/192.0.2.51/0000.0000.0000/192.0.2.1/13:11:00 UTC Thu Sep 24 2026])", + "log.ciscoTime": "Sep 24 13:11:00.500", + "log.facility": "SW_DAI", + "log.facilityMnemonic": "INVALID_ARP", + "log.msg": "SW_DAI-4-INVALID_ARP: 1 Invalid ARPs (Req) on Gi9/0/44, vlan 910.([0200.0000.0301/192.0.2.51/0000.0000.0000/192.0.2.1/13:11:00 UTC Thu Sep 24 2026])", + "log.severity": "4", + "actionResult": "blocked", + "severity": "medium" + }, + "alerts": [] + }, + "dhcpd-bad-octet": { + "logObject": true, + "fields": { + "log.ciscoMsg": "DHCP address conflict: server pinged 192.0.2.300.", + "log.ciscoTime": "*Sep 24 2026 09:55:22", + "log.facility": "DHCPD", + "log.facilityMnemonic": "PING_CONFLICT", + "log.msg": "DHCPD-4-PING_CONFLICT: DHCP address conflict: server pinged 192.0.2.300.", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "dhcpd-no-period": { + "logObject": true, + "fields": { + "log.ciscoMsg": "DHCP address conflict: server pinged 192.0.2.32", + "log.ciscoTime": "*Sep 24 2026 09:55:21", + "log.facility": "DHCPD", + "log.facilityMnemonic": "PING_CONFLICT", + "log.msg": "DHCPD-4-PING_CONFLICT: DHCP address conflict: server pinged 192.0.2.32", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "dhcpd-ping-conflict": { + "logObject": true, + "fields": { + "log.ciscoMsg": "DHCP address conflict: server pinged 192.0.2.31.", + "log.ciscoTime": "*Sep 24 2026 09:55:20", + "log.facility": "DHCPD", + "log.facilityMnemonic": "PING_CONFLICT", + "log.msg": "DHCPD-4-PING_CONFLICT: DHCP address conflict: server pinged 192.0.2.31.", + "log.severity": "4", + "target.ip": "192.0.2.31", + "severity": "medium" + }, + "alerts": [] + }, + "dtp-domain-mismatch": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Unable to perform trunk negotiation on port Gi9/0/46 because of VTP domain mismatch.", + "log.ciscoTime": "Sep 24 13:11:14.600", + "log.facility": "DTP", + "log.facilityMnemonic": "DOMAINMISMATCH", + "log.msg": "DTP-5-DOMAINMISMATCH: Unable to perform trunk negotiation on port Gi9/0/46 because of VTP domain mismatch.", + "log.severity": "5", + "severity": "low" + }, + "alerts": [ + "vlan_hopping_attempts" + ] + }, + "dtp-nontrunk": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Port Gi9/0/46 has become non-trunk", + "log.ciscoTime": "Sep 24 13:11:12.600", + "log.facility": "DTP", + "log.facilityMnemonic": "NONTRUNKPORTON", + "log.msg": "DTP-5-NONTRUNKPORTON: Port Gi9/0/46 has become non-trunk", + "log.severity": "5", + "severity": "low" + }, + "alerts": [ + "vlan_hopping_attempts" + ] + }, + "dtp-trunk": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Port Gi9/0/46 has become dot1q trunk", + "log.ciscoTime": "Sep 24 13:11:13.600", + "log.facility": "DTP", + "log.facilityMnemonic": "TRUNKPORTON", + "log.msg": "DTP-5-TRUNKPORTON: Port Gi9/0/46 has become dot1q trunk", + "log.severity": "5", + "severity": "low" + }, + "alerts": [ + "vlan_hopping_attempts" + ] + }, + "flap-colon-mac": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 02:00:00:00:01:06 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.ciscoTime": "Sep 24 13:09:53.020", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 02:00:00:00:01:06 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "flap-no-vlan": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.0000.0108 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.ciscoTime": "Sep 24 13:09:55.020", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0108 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "flap-slot-branch": { + "logObject": true, + "fields": { + "log.card": "LC", + "log.ciscoMsg": "Host 0200.0000.0105 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.ciscoTime": "Sep 24 13:09:52.020", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.firstPort": "Gi9/0/41", + "log.msg": "LC-4-MSG:SLOT3 %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0105 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.secondPort": "Gi9/0/42", + "log.severity": "4", + "log.slot": "SLOT3", + "log.vlan": "910", + "origin.mac": "0200.0000.0105", + "severity": "medium" + }, + "alerts": [] + }, + "flap-thirteen-hex": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.0000.01077 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.ciscoTime": "Sep 24 13:09:54.020", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.01077 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "flap-trailing-text": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.0000.0104 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42 (fabricated trailing text)", + "log.ciscoTime": "Sep 24 13:09:51.020", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.firstPort": "Gi9/0/41", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0104 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42 (fabricated trailing text)", + "log.secondPort": "Gi9/0/42", + "log.severity": "4", + "log.vlan": "910", + "origin.mac": "0200.0000.0104", + "severity": "medium" + }, + "alerts": [] + }, + "flap-truncated": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.0000.0109 in vlan 910 is flapping between port Gi9/0/41 and port", + "log.ciscoTime": "Sep 24 13:09:56.020", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0109 in vlan 910 is flapping between port Gi9/0/41 and port", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "flap-under-sw-vlan": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.0000.0110 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.ciscoTime": "Sep 24 13:09:57.020", + "log.facility": "SW_VLAN", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.msg": "SW_VLAN-4-MACFLAP_NOTIF: Host 0200.0000.0110 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [ + "vlan_hopping_attempts" + ] + }, + "flap-upper-hex-port-channel": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.00AB.CD01 in vlan 930 is flapping between port Po9 and port Gi9/0/43", + "log.ciscoTime": "Sep 24 13:09:50.020", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.firstPort": "Po9", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 0200.00AB.CD01 in vlan 930 is flapping between port Po9 and port Gi9/0/43", + "log.secondPort": "Gi9/0/43", + "log.severity": "4", + "log.vlan": "930", + "origin.mac": "0200.00AB.CD01", + "severity": "medium" + }, + "alerts": [] + }, + "header-dot": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.0000.0103 in vlan 910 is flapping between port Gi9/0/42 and port Gi9/0/41", + "log.ciscoTime": ".Sep 24 13:09:49.411", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.firstPort": "Gi9/0/42", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0103 in vlan 910 is flapping between port Gi9/0/42 and port Gi9/0/41", + "log.secondPort": "Gi9/0/41", + "log.severity": "4", + "log.vlan": "910", + "origin.mac": "0200.0000.0103", + "severity": "medium" + }, + "alerts": [] + }, + "header-seq-ms": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.0000.0101 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.ciscoTime": "Sep 24 13:09:48.182", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.firstPort": "Gi9/0/41", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0101 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "log.secondPort": "Gi9/0/42", + "log.severity": "4", + "log.vlan": "910", + "origin.mac": "0200.0000.0101", + "severity": "medium" + }, + "alerts": [] + }, + "header-star-year": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Host 0200.0000.0102 in vlan 920 is flapping between port Te9/1/2 and port Te9/1/1", + "log.ciscoTime": "*Sep 24 2026 09:54:02", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "MACFLAP_NOTIF", + "log.firstPort": "Te9/1/2", + "log.msg": "SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0102 in vlan 920 is flapping between port Te9/1/2 and port Te9/1/1", + "log.secondPort": "Te9/1/1", + "log.severity": "4", + "log.vlan": "920", + "origin.mac": "0200.0000.0102", + "severity": "medium" + }, + "alerts": [] + }, + "ip-dupaddr": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Duplicate address 192.0.2.54 on Vlan910, sourced by 0200.0000.0304", + "log.ciscoTime": "Sep 24 13:11:02.500", + "log.facility": "IP", + "log.facilityMnemonic": "DUPADDR", + "log.msg": "IP-4-DUPADDR: Duplicate address 192.0.2.54 on Vlan910, sourced by 0200.0000.0304", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "logginghost-fail": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Logging to host 192.0.2.41 port 514 failed", + "log.ciscoTime": "Sep 24 13:10:30.300", + "log.facility": "SYS", + "log.facilityMnemonic": "LOGGINGHOST_FAIL", + "log.msg": "SYS-3-LOGGINGHOST_FAIL: Logging to host 192.0.2.41 port 514 failed", + "log.severity": "3", + "target.ip": "192.0.2.41", + "target.port": 514, + "severity": "high" + }, + "alerts": [] + }, + "logginghost-host-name": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Logging to host logs.example.com port 514 failed", + "log.ciscoTime": "Sep 24 13:10:33.300", + "log.facility": "SYS", + "log.facilityMnemonic": "LOGGINGHOST_FAIL", + "log.msg": "SYS-3-LOGGINGHOST_FAIL: Logging to host logs.example.com port 514 failed", + "log.severity": "3", + "severity": "high" + }, + "alerts": [] + }, + "logginghost-port-eleven-digits": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Logging to host 192.0.2.45 port 12345678901 failed", + "log.ciscoTime": "Sep 24 13:10:35.300", + "log.facility": "SYS", + "log.facilityMnemonic": "LOGGINGHOST_FAIL", + "log.msg": "SYS-3-LOGGINGHOST_FAIL: Logging to host 192.0.2.45 port 12345678901 failed", + "log.severity": "3", + "severity": "high" + }, + "alerts": [] + }, + "logginghost-port-text": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Logging to host 192.0.2.44 port syslog failed", + "log.ciscoTime": "Sep 24 13:10:34.300", + "log.facility": "SYS", + "log.facilityMnemonic": "LOGGINGHOST_FAIL", + "log.msg": "SYS-3-LOGGINGHOST_FAIL: Logging to host 192.0.2.44 port syslog failed", + "log.severity": "3", + "severity": "high" + }, + "alerts": [] + }, + "logginghost-started": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Logging to host 192.0.2.42 port 6514 started - CLI initiated", + "log.ciscoTime": "Sep 24 13:10:31.300", + "log.facility": "SYS", + "log.facilityMnemonic": "LOGGINGHOST_STARTSTOP", + "log.msg": "SYS-6-LOGGINGHOST_STARTSTOP: Logging to host 192.0.2.42 port 6514 started - CLI initiated", + "log.severity": "6", + "target.ip": "192.0.2.42", + "target.port": 6514, + "severity": "low" + }, + "alerts": [] + }, + "logginghost-stopped": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Logging to host 192.0.2.43 port 514 stopped - CLI initiated", + "log.ciscoTime": "Sep 24 13:10:32.300", + "log.facility": "SYS", + "log.facilityMnemonic": "LOGGINGHOST_STARTSTOP", + "log.msg": "SYS-6-LOGGINGHOST_STARTSTOP: Logging to host 192.0.2.43 port 514 stopped - CLI initiated", + "log.severity": "6", + "severity": "low" + }, + "alerts": [] + }, + "mac-duplicate-text": { + "logObject": true, + "fields": { + "log.ciscoMsg": "duplicate MAC 0200.0000.0305 detected on Gi9/0/45", + "log.ciscoTime": "Sep 24 13:11:03.500", + "log.facility": "SW_MATM", + "log.facilityMnemonic": "DUPMAC", + "log.msg": "SW_MATM-4-DUPMAC: duplicate MAC 0200.0000.0305 detected on Gi9/0/45", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "misrouted-firepower-shape": { + "logObject": true, + "fields": { + "log.ciscoMsg": "EventPriority: Low, SrcIP: 198.51.100.7, DstIP: 192.0.2.10", + "log.facility": "FTD", + "log.facilityMnemonic": "430003", + "log.msg": "FTD-1-430003: EventPriority: Low, SrcIP: 198.51.100.7, DstIP: 192.0.2.10", + "log.severity": "1", + "severity": "high" + }, + "alerts": [] + }, + "severity-3-link": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Interface GigabitEthernet9/0/41, changed state to down", + "log.ciscoTime": "Sep 24 13:10:50.400", + "log.facility": "LINK", + "log.facilityMnemonic": "UPDOWN", + "log.msg": "LINK-3-UPDOWN: Interface GigabitEthernet9/0/41, changed state to down", + "log.severity": "3", + "severity": "high" + }, + "alerts": [] + }, + "severity-5-lineproto": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Line protocol on Interface GigabitEthernet9/0/41, changed state to up", + "log.ciscoTime": "Sep 24 13:10:51.400", + "log.facility": "LINEPROTO", + "log.facilityMnemonic": "UPDOWN", + "log.msg": "LINEPROTO-5-UPDOWN: Line protocol on Interface GigabitEthernet9/0/41, changed state to up", + "log.severity": "5", + "severity": "low" + }, + "alerts": [] + }, + "severity-5-subfacility": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Line protocol on Interface GigabitEthernet9/0/42, changed state to down", + "log.ciscoTime": "Sep 24 13:10:52.400", + "log.facility": "LINEPROTO", + "log.facilityMnemonic": "UPDOWN", + "log.msg": "LINEPROTO-SP-5-UPDOWN: Line protocol on Interface GigabitEthernet9/0/42, changed state to down", + "log.severity": "5", + "log.subFacility": "SP", + "severity": "low" + }, + "alerts": [] + }, + "sisf-colon-mac": { + "logObject": true, + "fields": { + "log.ciscoMsg": "R0/0: wncd: Excessive ARP activity detected for the client 02:00:00:00:02:03. client is brought down and added to the exclusion list", + "log.ciscoTime": "Sep 24 13:10:02.100", + "log.facility": "SISF", + "log.facilityMnemonic": "EXCESS_ARP_ACTIVITY", + "log.msg": "SISF-4-EXCESS_ARP_ACTIVITY: R0/0: wncd: Excessive ARP activity detected for the client 02:00:00:00:02:03. client is brought down and added to the exclusion list", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "sisf-excess-arp": { + "logObject": true, + "fields": { + "log.ciscoMsg": "R0/0: wncd: Excessive ARP activity detected for the client 0200.0000.0201. client is brought down and added to the exclusion list", + "log.ciscoTime": "Sep 24 13:10:00.100", + "log.facility": "SISF", + "log.facilityMnemonic": "EXCESS_ARP_ACTIVITY", + "log.msg": "SISF-4-EXCESS_ARP_ACTIVITY: R0/0: wncd: Excessive ARP activity detected for the client 0200.0000.0201. client is brought down and added to the exclusion list", + "log.severity": "4", + "origin.mac": "0200.0000.0201", + "severity": "medium" + }, + "alerts": [] + }, + "sisf-no-prefix": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Excessive ARP activity detected for the client 0200.0000.0202. client is brought down and added to the exclusion list", + "log.ciscoTime": "Sep 24 13:10:01.100", + "log.facility": "SISF", + "log.facilityMnemonic": "EXCESS_ARP_ACTIVITY", + "log.msg": "SISF-4-EXCESS_ARP_ACTIVITY: Excessive ARP activity detected for the client 0200.0000.0202. client is brought down and added to the exclusion list", + "log.severity": "4", + "origin.mac": "0200.0000.0202", + "severity": "medium" + }, + "alerts": [] + }, + "ssh-close": { + "logObject": true, + "fields": { + "log.ciscoMsg": "SSH Session from 198.51.100.23 (tty = 0) for user 'alice' using crypto cipher 'aes256-ctr', hmac 'hmac-sha2-256' closed", + "log.ciscoTime": "*Sep 24 2026 09:55:14", + "log.facility": "SSH", + "log.facilityMnemonic": "SSH_CLOSE", + "log.msg": "SSH-5-SSH_CLOSE: SSH Session from 198.51.100.23 (tty = 0) for user 'alice' using crypto cipher 'aes256-ctr', hmac 'hmac-sha2-256' closed", + "log.severity": "5", + "origin.ip": "198.51.100.23", + "severity": "low" + }, + "alerts": [] + }, + "ssh2-unexpected": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Unexpected message type has arrived. Terminating the connection from 198.51.100.21", + "log.ciscoTime": "Sep 24 13:10:10.200", + "log.facility": "SSH", + "log.facilityMnemonic": "SSH2_UNEXPECTED_MSG", + "log.msg": "SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 198.51.100.21", + "log.severity": "4", + "origin.ip": "198.51.100.21", + "severity": "medium" + }, + "alerts": [] + }, + "ssh2-unexpected-bad-octet": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Unexpected message type has arrived. Terminating the connection from 198.51.100.256", + "log.ciscoTime": "Sep 24 13:10:12.200", + "log.facility": "SSH", + "log.facilityMnemonic": "SSH2_UNEXPECTED_MSG", + "log.msg": "SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 198.51.100.256", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "ssh2-unexpected-ipv6": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Unexpected message type has arrived. Terminating the connection from 2001:db8::21", + "log.ciscoTime": "Sep 24 13:10:11.200", + "log.facility": "SSH", + "log.facilityMnemonic": "SSH2_UNEXPECTED_MSG", + "log.msg": "SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 2001:db8::21", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "ssh2-unexpected-trailing": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Unexpected message type has arrived. Terminating the connection from 198.51.100.22 port 22", + "log.ciscoTime": "Sep 24 13:10:13.200", + "log.facility": "SSH", + "log.facilityMnemonic": "SSH2_UNEXPECTED_MSG", + "log.msg": "SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 198.51.100.22 port 22", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [] + }, + "unparsed-no-percent": { + "logObject": false, + "fields": {}, + "alerts": [] + }, + "unparsed-percent-without-header": { + "logObject": true, + "fields": { + "log.ciscoTime": "Sep 24 13:10:41 host01.example.com monitor[42]: disk usage 91", + "log.msg": "on /var" + }, + "alerts": [] + }, + "vlan-inconsistency": { + "logObject": true, + "fields": { + "log.ciscoMsg": "VLAN 910 is inconsistent on port Gi9/0/46", + "log.ciscoTime": "Sep 24 13:11:10.600", + "log.facility": "SW_VLAN", + "log.facilityMnemonic": "VLAN_INCONSISTENCY", + "log.msg": "SW_VLAN-4-VLAN_INCONSISTENCY: VLAN 910 is inconsistent on port Gi9/0/46", + "log.severity": "4", + "severity": "medium" + }, + "alerts": [ + "vlan_hopping_attempts" + ] + }, + "vlan-trunk-mode-change": { + "logObject": true, + "fields": { + "log.ciscoMsg": "Port Gi9/0/46 trunk mode changed to on", + "log.ciscoTime": "Sep 24 13:11:11.600", + "log.facility": "SW_VLAN", + "log.facilityMnemonic": "TRUNK_MODE_CHANGE", + "log.msg": "SW_VLAN-5-TRUNK_MODE_CHANGE: Port Gi9/0/46 trunk mode changed to on", + "log.severity": "5", + "severity": "low" + }, + "alerts": [ + "vlan_hopping_attempts" + ] + }, + "vtp-mode-change": { + "logObject": true, + "fields": { + "log.ciscoMsg": "VLAN manager changing device mode from SERVER to TRANSPARENT.", + "log.ciscoTime": "Sep 24 13:11:15.600", + "log.facility": "SW_VLAN", + "log.facilityMnemonic": "VTPMODECHANGE", + "log.msg": "SW_VLAN-6-VTPMODECHANGE: VLAN manager changing device mode from SERVER to TRANSPARENT.", + "log.severity": "6", + "severity": "low" + }, + "alerts": [] + } + } +} diff --git a/plugins/alerts/testdata/cisco-switch/patterns.yaml b/plugins/alerts/testdata/cisco-switch/patterns.yaml new file mode 100644 index 000000000..46a1a0be2 --- /dev/null +++ b/plugins/alerts/testdata/cisco-switch/patterns.yaml @@ -0,0 +1,11 @@ +# The shared grok definitions this filter uses, copied from +# backend/src/main/resources/config/liquibase/changelog/20250616001_insert_utm_regex_pattern.xml. +patterns: + ciscoMacAddr: '(?:(?:[A-Fa-f0-9]{4}\.){2}[A-Fa-f0-9]{4})' + data: '(.*?)' + greedy: '.*' + integer: '(?:[+-]?(?:[0-9]+))' + ipv4: '(((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.)){3}((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)))' + monthName: '\b(?:[Jj]an(?:uary|uar)?|[Ff]eb(?:ruary|ruar)?|[Mm](?:a|รค)?r(?:ch|z)?|[Aa]pr(?:il)?|[Mm]a(?:y|i)?|[Jj]un(?:e|i)?|[Jj]ul(?:y|i)?|[Aa]ug(?:ust)?|[Ss]ep(?:tember)?|[Oo](?:c|k)?t(?:ober)?|[Nn]ov(?:ember)?|[Dd]e(?:c|z)(?:ember)?)\b' + notSpace: '\S+' + word: '\b\w+\b' diff --git a/plugins/alerts/testdata/cisco-switch/raw.json b/plugins/alerts/testdata/cisco-switch/raw.json new file mode 100644 index 000000000..4291a767d --- /dev/null +++ b/plugins/alerts/testdata/cisco-switch/raw.json @@ -0,0 +1,49 @@ +{ + "provenance": "FABRICATED. Cisco's system message documentation could not be read (the site refused automated access), so no line is claimed to be a documented Cisco format. The flap, SISF, SSH, DHCPD and logging-host lines copy the text shapes the filter's new steps read; the header, severity, SW_DAI, IP, VLAN and DTP lines follow the filter's own patterns. MAC addresses are in the locally administered range 02:00:00:xx:xx:xx written in Cisco's dotted form (0200.00xx.xxxx), addresses are RFC 5737 and RFC 3849 documentation addresses, and host, user and interface names are invented examples.", + "cases": { + "header-seq-ms": "<188>1001: Sep 24 13:09:48.182: %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0101 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "header-star-year": "<188>1002: *Sep 24 2026 09:54:02: %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0102 in vlan 920 is flapping between port Te9/1/2 and port Te9/1/1", + "header-dot": "<188>1003: .Sep 24 13:09:49.411: %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0103 in vlan 910 is flapping between port Gi9/0/42 and port Gi9/0/41", + "flap-upper-hex-port-channel": "<188>1004: Sep 24 13:09:50.020: %SW_MATM-4-MACFLAP_NOTIF: Host 0200.00AB.CD01 in vlan 930 is flapping between port Po9 and port Gi9/0/43", + "flap-trailing-text": "<188>1005: Sep 24 13:09:51.020: %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0104 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42 (fabricated trailing text)", + "flap-slot-branch": "<188>1006: Sep 24 13:09:52.020: %LC-4-MSG:SLOT3 %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0105 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "flap-colon-mac": "<188>1007: Sep 24 13:09:53.020: %SW_MATM-4-MACFLAP_NOTIF: Host 02:00:00:00:01:06 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "flap-thirteen-hex": "<188>1008: Sep 24 13:09:54.020: %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.01077 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "flap-no-vlan": "<188>1009: Sep 24 13:09:55.020: %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0108 is flapping between port Gi9/0/41 and port Gi9/0/42", + "flap-truncated": "<188>1010: Sep 24 13:09:56.020: %SW_MATM-4-MACFLAP_NOTIF: Host 0200.0000.0109 in vlan 910 is flapping between port Gi9/0/41 and port", + "flap-under-sw-vlan": "<188>1011: Sep 24 13:09:57.020: %SW_VLAN-4-MACFLAP_NOTIF: Host 0200.0000.0110 in vlan 910 is flapping between port Gi9/0/41 and port Gi9/0/42", + "sisf-excess-arp": "<188>1020: Sep 24 13:10:00.100: %SISF-4-EXCESS_ARP_ACTIVITY: R0/0: wncd: Excessive ARP activity detected for the client 0200.0000.0201. client is brought down and added to the exclusion list", + "sisf-no-prefix": "<188>1021: Sep 24 13:10:01.100: %SISF-4-EXCESS_ARP_ACTIVITY: Excessive ARP activity detected for the client 0200.0000.0202. client is brought down and added to the exclusion list", + "sisf-colon-mac": "<188>1022: Sep 24 13:10:02.100: %SISF-4-EXCESS_ARP_ACTIVITY: R0/0: wncd: Excessive ARP activity detected for the client 02:00:00:00:02:03. client is brought down and added to the exclusion list", + "ssh2-unexpected": "<188>1030: Sep 24 13:10:10.200: %SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 198.51.100.21", + "ssh2-unexpected-ipv6": "<188>1031: Sep 24 13:10:11.200: %SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 2001:db8::21", + "ssh2-unexpected-bad-octet": "<188>1032: Sep 24 13:10:12.200: %SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 198.51.100.256", + "ssh2-unexpected-trailing": "<188>1033: Sep 24 13:10:13.200: %SSH-4-SSH2_UNEXPECTED_MSG: Unexpected message type has arrived. Terminating the connection from 198.51.100.22 port 22", + "ssh-close": "<189>1034: *Sep 24 2026 09:55:14: %SSH-5-SSH_CLOSE: SSH Session from 198.51.100.23 (tty = 0) for user 'alice' using crypto cipher 'aes256-ctr', hmac 'hmac-sha2-256' closed", + "dhcpd-ping-conflict": "<188>1040: *Sep 24 2026 09:55:20: %DHCPD-4-PING_CONFLICT: DHCP address conflict: server pinged 192.0.2.31.", + "dhcpd-no-period": "<188>1041: *Sep 24 2026 09:55:21: %DHCPD-4-PING_CONFLICT: DHCP address conflict: server pinged 192.0.2.32", + "dhcpd-bad-octet": "<188>1042: *Sep 24 2026 09:55:22: %DHCPD-4-PING_CONFLICT: DHCP address conflict: server pinged 192.0.2.300.", + "logginghost-fail": "<187>1050: Sep 24 13:10:30.300: %SYS-3-LOGGINGHOST_FAIL: Logging to host 192.0.2.41 port 514 failed", + "logginghost-started": "<190>1051: Sep 24 13:10:31.300: %SYS-6-LOGGINGHOST_STARTSTOP: Logging to host 192.0.2.42 port 6514 started - CLI initiated", + "logginghost-stopped": "<190>1052: Sep 24 13:10:32.300: %SYS-6-LOGGINGHOST_STARTSTOP: Logging to host 192.0.2.43 port 514 stopped - CLI initiated", + "logginghost-host-name": "<187>1053: Sep 24 13:10:33.300: %SYS-3-LOGGINGHOST_FAIL: Logging to host logs.example.com port 514 failed", + "logginghost-port-text": "<187>1054: Sep 24 13:10:34.300: %SYS-3-LOGGINGHOST_FAIL: Logging to host 192.0.2.44 port syslog failed", + "logginghost-port-eleven-digits": "<187>1055: Sep 24 13:10:35.300: %SYS-3-LOGGINGHOST_FAIL: Logging to host 192.0.2.45 port 12345678901 failed", + "unparsed-no-percent": "<13>Sep 24 13:10:40 host01.example.com sshd[123]: Accepted password for alice from 198.51.100.7 port 22 ssh2", + "unparsed-percent-without-header": "<13>Sep 24 13:10:41 host01.example.com monitor[42]: disk usage 91% on /var", + "misrouted-firepower-shape": "<185>%FTD-1-430003: EventPriority: Low, SrcIP: 198.51.100.7, DstIP: 192.0.2.10", + "severity-3-link": "<187>1060: Sep 24 13:10:50.400: %LINK-3-UPDOWN: Interface GigabitEthernet9/0/41, changed state to down", + "severity-5-lineproto": "<189>1061: Sep 24 13:10:51.400: %LINEPROTO-5-UPDOWN: Line protocol on Interface GigabitEthernet9/0/41, changed state to up", + "severity-5-subfacility": "<189>1062: Sep 24 13:10:52.400: %LINEPROTO-SP-5-UPDOWN: Line protocol on Interface GigabitEthernet9/0/42, changed state to down", + "dai-invalid-arp": "<188>1070: Sep 24 13:11:00.500: %SW_DAI-4-INVALID_ARP: 1 Invalid ARPs (Req) on Gi9/0/44, vlan 910.([0200.0000.0301/192.0.2.51/0000.0000.0000/192.0.2.1/13:11:00 UTC Thu Sep 24 2026])", + "dai-dhcp-snooping-deny": "<188>1071: Sep 24 13:11:01.500: %SW_DAI-4-DHCP_SNOOPING_DENY: 1 Invalid ARPs (Res) on Gi9/0/44, vlan 910.([0200.0000.0302/192.0.2.52/0200.0000.0303/192.0.2.53/13:11:01 UTC Thu Sep 24 2026])", + "ip-dupaddr": "<188>1072: Sep 24 13:11:02.500: %IP-4-DUPADDR: Duplicate address 192.0.2.54 on Vlan910, sourced by 0200.0000.0304", + "mac-duplicate-text": "<188>1073: Sep 24 13:11:03.500: %SW_MATM-4-DUPMAC: duplicate MAC 0200.0000.0305 detected on Gi9/0/45", + "vlan-inconsistency": "<188>1080: Sep 24 13:11:10.600: %SW_VLAN-4-VLAN_INCONSISTENCY: VLAN 910 is inconsistent on port Gi9/0/46", + "vlan-trunk-mode-change": "<189>1081: Sep 24 13:11:11.600: %SW_VLAN-5-TRUNK_MODE_CHANGE: Port Gi9/0/46 trunk mode changed to on", + "dtp-nontrunk": "<189>1082: Sep 24 13:11:12.600: %DTP-5-NONTRUNKPORTON: Port Gi9/0/46 has become non-trunk", + "dtp-trunk": "<189>1083: Sep 24 13:11:13.600: %DTP-5-TRUNKPORTON: Port Gi9/0/46 has become dot1q trunk", + "dtp-domain-mismatch": "<189>1084: Sep 24 13:11:14.600: %DTP-5-DOMAINMISMATCH: Unable to perform trunk negotiation on port Gi9/0/46 because of VTP domain mismatch.", + "vtp-mode-change": "<190>1085: Sep 24 13:11:15.600: %SW_VLAN-6-VTPMODECHANGE: VLAN manager changing device mode from SERVER to TRANSPARENT." + } +} diff --git a/plugins/alerts/testdata/cisco-switch/replay.py b/plugins/alerts/testdata/cisco-switch/replay.py new file mode 100644 index 000000000..8a75e4467 --- /dev/null +++ b/plugins/alerts/testdata/cisco-switch/replay.py @@ -0,0 +1,202 @@ +"""Replay fabricated Cisco switch raw lines through separately built EventProcessor binaries. + +Requires PyYAML. This does not build or deploy anything and uses only local file writers. +It stages the current filter, the shared grok definitions (patterns.yaml) and the three Cisco +switch rules, runs the playground once, and checks every event against expected.json and every +local alert against its expected rule. The MAC spoofing and ARP poisoning rules have history +searches. Their OpenSearch address is a closed local port: no fixture may reach a history +search, and an attempt would fail and be reported. Every input is fabricated. See +filters/audits/cisco-switch.md. +""" +import argparse +import errno +import hashlib +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import tempfile + +import yaml + +PLUGINS = ("add", "cast", "cel", "delete", "grok", "saw", "sew", "trim") +TENANT = "00000000-0000-4000-8000-000000000001" +ENVELOPE_TIMESTAMP = "2026-09-24T14:00:00Z" +ENVELOPE = ("id", "timestamp", "deviceTime", "dataType", "dataSource", "tenantId", "tenantName", "raw", "errors") +LOG_FAILURES = ("failed to unmarshal", "failed to evaluate", "failed to execute correlation search", + "plugin not found", "failed to compile", "failed to start plugin", + "failed to convert log to event", "all retries failed", "panic") + + +def records(path): + # The playground writers can append adjacent JSON objects before newlines. + content = path.read_text() if path.exists() else "" + decoder = json.JSONDecoder() + result, offset = [], 0 + while offset < len(content): + while offset < len(content) and content[offset].isspace(): + offset += 1 + if offset < len(content): + record, offset = decoder.raw_decode(content, offset) + result.append(record) + return result + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def flatten(value, prefix=""): + """Dotted paths of every leaf; an empty object is kept as a leaf.""" + out = {} + if isinstance(value, dict) and (value or not prefix): + for key, item in value.items(): + out.update(flatten(item, f"{prefix}.{key}" if prefix else key)) + else: + out[prefix] = value + return out + + +def fields(event): + return flatten({k: v for k, v in event.items() if k not in ENVELOPE}) + + +def closed_port(): + # A port nothing listens on: bind an ephemeral port, then release it. + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +def listening(port): + with socket.socket() as probe: + probe.settimeout(1) + return probe.connect_ex(("127.0.0.1", port)) == 0 + + +def place(source, target): + target.parent.mkdir(parents=True, exist_ok=True) + try: + os.link(source, target) + except OSError as error: + if error.errno != errno.EXDEV: + raise + shutil.copy2(source, target) + + +def run(playground, plugins): + """Stage everything in a fresh private directory, run the playground, return its results.""" + fixture_dir = Path(__file__).resolve().parent + root = fixture_dir.parents[3] + os.umask(0o077) + work = Path(tempfile.mkdtemp(prefix="csw-pg-", dir="/tmp")) + print(f"Local evidence directory: {work}", flush=True) + for part in ("input", "output", "pipeline/filters", "rules", "plugins", "sockets", "geolocation"): + (work / part).mkdir(parents=True, exist_ok=True) + binaries = {"playground": playground.resolve()} + for name in PLUGINS: + source = (plugins / f"{name}.plugin").resolve() + require(source.is_file(), f"Missing binary: {source}") + place(source, work / "plugins" / source.name) + binaries[name] = source + port = closed_port() + require(not listening(port), f"Port {port} is in use") + config = { + "tenants": [{"id": TENANT, "name": "fixture"}], + "plugins": { + "analysis": {"order": ["sew", "cel"]}, + "correlation": {"order": ["saw"]}, + "notification": {"order": []}, + # CEL builds a client at start-up; nothing listens here, so a history search would fail. + "org.opensearch": {"opensearch": f"http://127.0.0.1:{port}"}, + }, + } + (work / "pipeline/config.yaml").write_text(yaml.safe_dump(config)) + shutil.copy2(fixture_dir / "patterns.yaml", work / "pipeline/patterns.yaml") + filter_path = root / "filters/cisco/cs_switch.yml" + shutil.copy2(filter_path, work / "pipeline/filters/cs_switch.yaml") + rule_paths = sorted((root / "rules/cisco/cs_switch").glob("*.y*ml")) + require(len(rule_paths) == 3, f"Expected 3 Cisco switch rules, found {len(rule_paths)}") + stems = {} + for offset, path in enumerate(rule_paths): + rule = yaml.safe_load(path.read_text()) + require(isinstance(rule, dict) and "id" not in rule, f"Unexpected rule shape: {path.name}") + rule["id"] = 9001 + offset # the playground loader needs unique non-zero ids + stems[rule["name"]] = path.stem + (work / "rules" / f"{rule['id']}-{path.stem}.yaml").write_text(yaml.safe_dump([rule])) + cases = json.loads((fixture_dir / "raw.json").read_text())["cases"] + inputs = {} + for number, name in enumerate(sorted(cases)): + event_id = f"cisco-switch-{name}" + inputs[event_id] = (name, cases[name]) + event = {"id": event_id, "dataType": "cisco-switch", "dataSource": "fixture-switch", + "@timestamp": ENVELOPE_TIMESTAMP, "tenantId": TENANT, "raw": cases[name]} + (work / "input" / f"{number:03d}.json").write_text(json.dumps(event)) + hashed = [filter_path, *rule_paths] + [fixture_dir / n for n in ("patterns.yaml", "raw.json")] + manifest = { + "provenance": "fabricated raw inputs; no customer data", + "historyBackend": f"none: http://127.0.0.1:{port} is closed", + "sourceHashes": {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in hashed}, + "binaries": {name: { + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "buildInfo": subprocess.check_output(["go", "version", "-m", str(path)], text=True), + } for name, path in binaries.items()}, + } + (work / "manifest.json").write_text(json.dumps(manifest, indent=2)) + env = dict(os.environ, WORK_DIR=str(work), MODE="playground") + with (work / "execution.log").open("w") as log: + subprocess.run([str(binaries["playground"])], env=env, stdout=log, + stderr=subprocess.STDOUT, check=True, timeout=900) + require(not listening(port), f"Something started listening on port {port} during the run") + log_text = (work / "execution.log").read_text() + events = records(work / "output/resulting_log.json") + alerts = records(work / "output/resulting_alert.json") + return work, inputs, stems, events, alerts, log_text + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--playground", required=True, type=Path) + parser.add_argument("--plugins", required=True, type=Path) + args = parser.parse_args() + fixture_dir = Path(__file__).resolve().parent + expected = json.loads((fixture_dir / "expected.json").read_text())["cases"] + work, inputs, stems, events, alerts, log_text = run(args.playground, args.plugins) + require(set(expected) == {name for name, _ in inputs.values()}, "raw.json and expected.json disagree") + for marker in LOG_FAILURES: + require(marker not in log_text, f"Execution log reports: {marker}") + + by_id = {e.get("id"): e for e in events} + require(len(events) == len(inputs) == len(by_id), f"Events {len(events)} for {len(inputs)} inputs") + for event_id, (name, raw) in inputs.items(): + event, want = by_id[event_id], expected[name] + require(event.get("raw") == raw, f"Raw input changed: {event_id}") + require(not event.get("errors"), f"Parser errors: {event_id}: {len(event.get('errors') or [])}") + require(("log" in event) == want["logObject"], f"{event_id}: log object present={'log' in event}") + got = fields(event) + differ = sorted(k for k in set(got) | set(want["fields"]) if got.get(k, "") != want["fields"].get(k, "")) + require(not differ, f"{event_id}: fields differ: " + + "; ".join(f"{k}={got.get(k, '')!r}, want {want['fields'].get(k, '')!r}" for k in differ[:5])) + + fired = {} + for alert in alerts: + require(not alert.get("errors") and not alert.get("name", "").startswith("Circuit Breaker"), + f"Rule evaluation failure: {alert.get('name')}") + require(alert.get("name") in stems, f"Unknown alert: {alert.get('name')}") + ids = [e.get("id") for e in alert.get("events", [])] + require(ids and ids[-1] in inputs, f"Unexpected alert events: {alert.get('name')} {ids}") + fired.setdefault(ids[-1], []).append(stems[alert["name"]]) + for event_id, (name, _) in inputs.items(): + got = sorted(fired.get(event_id, [])) + require(got == sorted(expected[name]["alerts"]), f"{event_id}: alerts {got}, want {sorted(expected[name]['alerts'])}") + result = {"passed": True, "events": len(events), "alerts": len(alerts)} + (work / "assertions.json").write_text(json.dumps(result)) + print(f"PASS: {len(events)} raw events, zero parser errors, {len(alerts)} local alerts, each from its " + f"intended rule, no Circuit Breaker and no history search attempted") + + +if __name__ == "__main__": + main() From ce3b66a54b8d1afba36a377edbf9991ca6f945fc Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 12:30:53 -0400 Subject: [PATCH 4/7] docs(cisco-switch): add filter and rule audit Evidence basis (real switch records on two instances, given as counts and header shapes only; the production circuit-breaker alerts of the MAC rule; Cisco's documentation unavailable), the basis and proof of each change, the MAC rule decision with the estimated volumes of each option as an owner decision, the playground, SDK and Go test results, the deferred items with what would unblock each, the customer-side routing note and the known limits. Co-Authored-By: Claude Opus 5.5 --- filters/audits/cisco-switch.md | 252 +++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 filters/audits/cisco-switch.md diff --git a/filters/audits/cisco-switch.md b/filters/audits/cisco-switch.md new file mode 100644 index 000000000..43466485c --- /dev/null +++ b/filters/audits/cisco-switch.md @@ -0,0 +1,252 @@ +# Cisco switch v11 filter and rule review + +The Cisco switch filter (`filters/cisco/cs_switch.yml`) and its three rules had defects that real +switch records, the filter's own patterns, the event engine and the SDK make visible. The MAC +address spoofing rule matched every MAC flap notification, but no step wrote the `origin.mac` its +history search needs, so the rule failed on every flap, disabled itself after five failures and +raised `Circuit Breaker` alerts in production; it has never produced a detection. The ARP +poisoning rule had the same unresolved history value on every branch. The one raw `log.*` +comparison in the filter stored an error on every line without a Cisco header. Addresses that six +real message types carry in their text were never mapped. This revision fixes those points and +nothing else. The schema is ThreatWinds go-sdk **v1.1.33**, as pinned by `plugins/alerts/go.mod`. + +## Evidence basis + +- **Real records, read only.** 29 of the 31 v11 instances could be searched; on one the search + service did not answer, and one had no SSH access. Two instances hold Cisco switch records under + this data type, and a third receives only other devices' logs on this input (see "For the + owner"). Nothing was written to any instance. Record contents, addresses, instance names and + document identifiers stay in the private review; this file gives counts only. + + | Message class (all time, 2026-09-24) | Instance A | Instance B | + |---|---|---| + | Records (senders), first record | 225,974 (5), 2026-08-24 | 2,618 (1), 2026-08-26 | + | `SW_MATM-4-MACFLAP_NOTIF` | 179,487 | 2,519 | + | `LINK-3-UPDOWN` / `LINEPROTO-5-UPDOWN` | 36,529 / 0 | 41 / 49 | + | `SISF-4-EXCESS_ARP_ACTIVITY` | 4,048 | 0 | + | Wireless access point traces (`CAPWAPAC_SMGR_TRACE_MESSAGE`, `APMGR_TRACE_MESSAGE`) | 3,143 | 0 | + | `PKI` (7 mnemonics) | 2,512 | 0 | + | `IP-3-LOOPPAK` | 112 | 0 | + | `SYS-3-LOGGINGHOST_FAIL` / `SYS-6-LOGGINGHOST_STARTSTOP` | 97 / 0 | 0 / 1 | + | `SSH-4-SSH2_UNEXPECTED_MSG` / `SSH-5-SSH_CLOSE` | 5 / 0 | 0 / 2 | + | `DHCPD-4-PING_CONFLICT` | 0 | 5 | + | Optics, licensing, CPU and platform messages | 41 | 1 | + | Records stored with an error | 0 | 0 | + + Every record on both instances has one of two header shapes, both handled by the filter's first + time step: `seq: Mon DD HH:MM:SS.mmm: %FACILITY-SEVERITY-MNEMONIC: text` (instance A; a + small share has a leading `.` before the time) and `seq: *Mon DD YYYY HH:MM:SS: %...` + (instance B). No access-list, 802.1X, `SW_DAI`, `DTP`, `SW_VLAN`, `IP-4-DUPADDR` or + `IP SOURCEGUARD` record exists on any searched instance. The deployed filter and rules (engine + v11.2.13) are identical to this repository's on the three instances. +- **Production circuit breakers.** The alert indices hold 12 `Circuit Breaker: MAC Address + Spoofing Detection` alerts: 2 on instance A (2026-09-04 to 2026-09-21) and 10 on instance B + (2026-08-19 to 2026-09-21). Each reads "The rule MAC Address Spoofing Detection has been + temporarily disabled after failing 5 times during processing.", carries the error + `expression value cannot be nil after placeholder resolution`, was triggered by a + `SW_MATM`/`MACFLAP_NOTIF` event and is stored with severity High; the latest ones are Open. + No `MAC Address Spoofing Detection`, `ARP Poisoning Attack Detection` or `VLAN Hopping Attack + Detection` alert exists on any of the three instances. +- **Cisco documentation was not available.** The allowed fetch tool received HTTP 403 for the + system message guide the filter cites and for the two Cisco pages the rules cite (one request + each, 2026-09-24 15:31 UTC; not retried or worked around). No fixture is taken from Cisco's + documentation, and nothing that depends on what a message means, which port a flap moved + from, or how an unobserved message is laid out is changed. MITRE ATT&CK v19.2 was read; no + label changes (see D-12). +- **What the corrections rest on:** the real text shapes above; the public EventProcessor at + commit `497bf53dbd1ae096f7b2dbc7bce77a6bf9f22ce1`, whose + [parser](https://github.com/utmstack/EventProcessor/blob/497bf53dbd1ae096f7b2dbc7bce77a6bf9f22ce1/pkg/parsing/parsing.go) + stores an error and skips the step when a `where` clause fails, whose + [grok plugin](https://github.com/utmstack/EventProcessor/blob/497bf53dbd1ae096f7b2dbc7bce77a6bf9f22ce1/plugins/grok/main.go) + writes nothing unless every pattern matched at the start of the remaining text, and whose + [CEL plugin](https://github.com/utmstack/EventProcessor/blob/497bf53dbd1ae096f7b2dbc7bce77a6bf9f22ce1/plugins/cel/main.go) + disables a rule at its fifth error with a `Circuit Breaker: ` alert; and go-sdk + v1.1.33, whose [`plugins/cel.go`](https://github.com/threatwinds/go-sdk/blob/v1.1.33/plugins/cel.go) + declares a CEL variable only for the top-level keys an event has (so `log.severity=="4"` fails + without a `log` object, while `equals` returns false), whose + [`plugins/rules.go`](https://github.com/threatwinds/go-sdk/blob/v1.1.33/plugins/rules.go) + returns an error when a `{{.field}}` history value cannot be resolved, and whose + [`plugins/plugins.proto`](https://github.com/threatwinds/go-sdk/blob/v1.1.33/plugins/plugins.proto) + makes `origin.mac` and `origin.ip` strings and `origin.port`/`target.port` whole numbers. + +## Filter changes (version 3.1.0) + +| Id | Change | Why | Proof | +|---|---|---|---| +| F-1 | Line 206: `where: log.severity=="4"` becomes `equals("log.severity", "4")`. | A line without a `%FACILITY-SEVERITY-MNEMONIC` header has no `log` object or no `log.severity`, so the raw clause failed and an error was stored on the event. It was the only raw clause among the filter's 16. | Playground, 398 inputs (327 real, 71 fabricated): events with errors 25 to 0; severity identical on 398 of 398. Go test: every clause runs without an error on drafts without `log`, without a severity and parsed; severity unchanged on levels 0 to 7. | +| F-2 | `SW_MATM-4-MACFLAP_NOTIF`: `Host in vlan is flapping between port and port ` gives `origin.mac`, `log.vlan`, `log.firstPort` and `log.secondPort`. | Every stored flap record has this text, but the address stayed inside `log.ciscoMsg`. Interface names are text, so they stay under `log.*`; the ports keep the order the message gives, because which one is the previous port is not established (D-2). | Playground: 30 of 30 real flap records get all four fields, each equal to an independent reading of the text; fabricated upper-case, port-channel, card-slot and trailing-text lines extracted; colon MAC, 13 hex digits, missing VLAN, truncated port and `SW_VLAN` lines untouched. | +| F-3 | `SISF-4-EXCESS_ARP_ACTIVITY`: the client address to `origin.mac`. | All stored records have `... Excessive ARP activity detected for the client . client is brought down ...`. | Playground: 20 of 20 real records; colon-form refused. | +| F-4 | `SSH-4-SSH2_UNEXPECTED_MSG` and `SSH-5-SSH_CLOSE`: the client address to `origin.ip`. | `Terminating the connection from ` and `SSH Session from (tty ...` name the client. | Playground: 5 of 5 and 1 of 1 real records; IPv6, invalid octet and trailing-text lines refused. | +| F-5 | `DHCPD-4-PING_CONFLICT`: the pinged address to `target.ip`. | `server pinged .` names the address the server tested. | Playground: 5 of 5 real records; no final period and invalid octet refused. | +| F-6 | `SYS-3-LOGGINGHOST_FAIL` and `SYS-6-LOGGINGHOST_STARTSTOP`: the logging host to `target.ip` and its port to `target.port`, cast to a number. | `Logging to host port failed/started` names where the switch sends its logs. | Playground: 10 of 10 and 1 of 1 real records, `target.port` a JSON number; `stopped`, host name, text, 11-digit and negative ports refused. | + +Every new step runs only for its facility and mnemonic. The `actionResult` steps (lines 159-192) +and the severity words are unchanged (D-9, D-10). + +## Rule changes + +Names, impact, category, technique, adversary side, references, thresholds and windows are +unchanged. `vlan_hopping_attempts.yml` is unchanged. + +| Rule | Change | Why | +|---|---|---| +| `mac_address_spoofing` (v1.0.1) | Require `origin.mac`; leave out flap notifications (`!regexMatch("log.msg", "(?i)(mac.*flap\|is flapping between port)")`); read `log.msg` instead of `log.message`; `deduplicateBy: adversary.mac` instead of `groupBy`; the description says flaps are not used. | The flap branch matched every flap and failed its `{{.origin.mac}}` history value, which caused the production circuit breakers. With F-2 the value resolves, so the unchanged rule would run a history search on every flap. Nothing shows that a flap means an address was copied (see the decision below). Nothing writes `log.message`. | +| `arp_poisoning_detection` (v1.0.1) | Wrap the condition in `exists("origin.ip") && (...)`; read `log.msg` instead of `log.message`. | No step writes `origin.ip` for `SW_DAI`, `IP DUPADDR/SOURCEGUARD` or the text branches, so any match would fail its `{{.origin.ip}}` history value in the same way. No such message has arrived yet. | + +F-2 and the MAC rule change must ship together. The rule commit comes first on this branch, so +every commit is safe on its own. + +## The MAC rule decision + +The flap trigger is left out of the spoofing rule. This is an owner decision that the reviewer +can revisit. It rests on the real flap patterns, because Cisco's explanation could not be read +and ATT&CK v19.2 has no MAC spoofing technique: + +- Instance A: one address alternates between the same two ports about every 15 seconds, on 31 + of 31 days, with no other address involved. +- Instance B: 438 addresses in 30 days. Five port pairs carry 93% of the flaps, and each pair + saw 73 to 116 different addresses. 64% of the unchanged rule's would-be firings happen while + other addresses flap on the same ports. + +A standing path problem or a moving host fits both patterns; a copied address would also cause +flaps, but the message carries nothing that tells the cases apart, and a history block can only +require at least N hits. Estimated volumes over the same 30 days, from the stored flap +timestamps (a model of the SDK history search, not observed alerts): + +| Option (30 days) | Instance A | Instance B | +|---|---|---| +| O0: map `origin.mac`, rule unchanged | 172,614-175,323 indexed alerts under one parent | 488-500 indexed alerts | +| O1: deduplicate, 3 in 10 minutes | 5 alerts from 172,614-175,323 firings | 115-119 alerts from 488-500 firings | +| O3: deduplicate, 10 in 10 minutes | 5 alerts from 172,576-175,253 firings | 9 alerts from 54 firings | +| O5a / O5b: storms only, 50 / 100 in 10 minutes | 4-5 / 2 alerts from 1,212-12,445 / 620-3,058 firings | 0 / 0 | +| **O4: no flap trigger (this revision)** | **0** | **0** | + +Every firing also reaches the correlation plugins, including automatic AI analysis when it is +on, even when deduplication hides the alert. If the owner wants flaps to alert, a separately +named network-health rule on flap storms (O5) fits the data better than the spoofing rule (D-1). +With O4 the rule keeps its duplicate-MAC, MAC-conflict, MAC-move and `SW_DAI` branches, which +need an `origin.mac` producer for those messages (D-3); on today's data it has no live path, as +before, but it no longer disables itself. + +## Validation + +**Fabricated regression, committed.** `plugins/alerts/testdata/cisco-switch/` holds 44 invented +raw lines (`raw.json`), their expected fields and alerts (`expected.json`), the 8 shared grok +definitions the filter uses (`patterns.yaml`, copied from +`20250616001_insert_utm_regex_pattern.xml`; identical to the deployed definitions) and +`replay.py`. MAC addresses are in the locally administered range 02:00:00:xx:xx:xx in Cisco's +dotted form, addresses are RFC 5737 and RFC 3849 documentation addresses, and host, user and +interface names are examples. The flap, SISF, SSH, DHCPD and logging-host lines copy the observed +text shapes; the `SW_DAI`, `IP`, `SW_VLAN` and `DTP` lines follow the filter's and rules' own +patterns, because no such record or documentation was available. + +**Playground.** A clean build of the EventProcessor commit above, whose parser and writer plugins +link go-sdk v1.1.26 and whose CEL plugin links v1.1.34; every binary reproduced its recorded +checksum. File input, one fresh private working directory per run. + +| Run | Result | +|---|---| +| Original filter, 398 inputs (327 real records from three instances, 71 fabricated) | 398 events, identical to an earlier run of the same inputs. 25 events carry the line-206 error (17 without a `log` object, 8 without a severity); on the 327 real records the playground reproduced every stored document, error texts included. | +| Corrected filter, same 398 inputs | 398 events, no errors, severity identical on 398. Every difference from the original run is an intended new field: `origin.mac` 59, `log.vlan`/`log.firstPort`/`log.secondPort` 37 each, `target.ip` 21, `target.port` 13 (all numbers), `origin.ip` 9; 30 of 30 real flap records and 42 of 42 real SISF, SSH, DHCPD and logging-host records carry their fields; 18 of 18 fabricated near-misses carry none; no event has `origin.port`. | +| Corrected filter and the three rules, 20 fabricated lines: 8 flaps of one address within two minutes, 2 `SW_DAI`, 1 each of SISF, SSH, DHCPD and logging host, 6 `SW_VLAN`/`DTP` | 20 events without errors. Six VLAN hopping alerts, exactly on the six `SW_VLAN`/`DTP` lines; no MAC, ARP or `Circuit Breaker` alert; no compile, rule or history search error. The rules' OpenSearch address was a closed local port and no history search was attempted. | +| Corrected filter and the ORIGINAL rules, same 20 lines | The original MAC rule reached its history search on all 8 flaps (their `origin.mac` now resolves); each search failed because nothing listened. Both `SW_DAI` lines failed the MAC and the ARP rule with `expression value cannot be nil after placeholder resolution`. One `Circuit Breaker: MAC Address Spoofing Detection` alert; the same six VLAN alerts. | +| Committed `replay.py`, 44 lines | 44 events without errors, every stored field as recorded in `expected.json`; six alerts, all from the VLAN hopping rule; no `Circuit Breaker` and no history search attempted. | + +**SDK predicate checks.** The go-sdk v1.1.33 rule replay evaluated the committed rules over the +corrected filter's playground output of all 2,795 distinct texts behind the 229,017 switch +records stored on the two instances when they were collected (182,326 of them flaps), and over +the 398 events above. The committed MAC and ARP rules match none of them; the original MAC rule +matches all 182,326 flap records, and with the corrected filter their history value now resolves. +15 of 15 synthetic checks pass: duplicate-MAC, MAC-conflict and `SW_DAI` events with `origin.mac` +match the MAC rule with the value resolved, `SW_DAI` with `origin.ip` matches the ARP rule, and +flap, SISF, SSH and address-less `SW_DAI` events match neither. `TestFilterAndRuleContracts` +passes for the filter and the three rules. + +**Go tests.** `cisco_switch_filter_test.go` has eight tests. They check that no `where` clause +compares `log.*` directly and every clause runs without an error without a `log` object or a +severity; that the severity steps keep their results; that a model of the engine's step plugins, +with every `where` clause evaluated by go-sdk v1.1.33, gives the playground's result for every +stored field of the 44 lines, including a positive and a near-miss line for each new mapping; +that no interface name reaches `origin.port` or `target.port`; the rules' names, metadata, +impact, grouping and history searches and the unchanged VLAN condition; 28 synthetic rule cases; +that the MAC and ARP rules match none of the 44 lines and the VLAN rule exactly the six +`SW_VLAN`/`DTP` lines; and that whenever a rule with a history search matches, its history values +resolve. All eight fail against the original filter and rules and pass against this revision. +The full `plugins/alerts` suite passes: 51 tests pass, and the same 11 tests that need other +technologies' private evidence skip, as they do on the base commit (43 pass, 11 skip). + +## Deferred + +Each of these needs Cisco's documentation, real records or an owner decision, and is unchanged +here. + +| Id | What | What would unblock it | +|---|---|---| +| D-1 | Whether flap notifications should raise any alert, including the MAC-move text branch. | Cisco's explanation of `SW_MATM-4-MACFLAP_NOTIF`, and an owner decision; if wanted, a separately named network-health rule, not the spoofing rule. | +| D-2 | Which flap port is the previous one (rename `log.firstPort`/`log.secondPort` to a direction). | Cisco's message layout, or a lab test moving one host between known ports. | +| D-3 | Address mapping for `SW_DAI`, `IP DUPADDR` and `SOURCEGUARD`; the wording and case of the ARP and MAC text branches (the ARP phrases are case-sensitive); one event firing both rules. | Cisco layouts and real records (none exist). | +| D-4 | VLAN rule: its `log.message` text branches, `MACFLAP_NOTIF` listed under `SW_VLAN`, grouping keys no step writes. Reading `log.msg` would wake text branches that have no threshold and no guard. | Cisco wording and real `SW_VLAN`/`DTP` records. | +| D-5 | New rules for SISF excess ARP, logging-host failures, DHCP conflicts and PKI failures. | Cisco explanations and an owner decision. | +| D-6 | Access-list field extraction. | Cisco formats and real records (none). | +| D-7 | `deviceTime` from the header time: no sender includes a time zone, one writes local time. | Cisco timestamp options; a sender that sends year and zone. | +| D-8 | Header variants and subfacilities that contain digits. | Cisco documentation or real records. | +| D-9 | Severity words `high`/`medium`/`low` instead of the SDK wiki's values. | Owner decision after a dashboard and saved-search review. | +| D-10 | `actionResult` values `failed` and `blocked`. | The separate action-result correction. | +| D-11 | Interface and state of link messages. | A consumer that needs them. | +| D-12 | ATT&CK v19 tactic names (Defense Evasion is now Stealth; T1599 moved to Defense Impairment). | A repository-wide owner decision. | + +## For the owner + +On one instance, Firepower Threat Defense events and the management center's Linux system lines +arrive on the Cisco switch input, because those devices send to the port assigned to this data +type. About 1.12 million of those records carried the line-206 error. F-1 only removes that +error. The Firepower events are still parsed as switch messages, stored with severity `high` +and their addresses left in text, and neither the Firepower filter nor its rules see them. After +this change the misrouting no longer shows up as errors; track it by facility `FTD` and by +records without `log.facility`. The fix is on the customer side: point those devices at the +Firepower input, decide whether the management center's system log should be collected, and +check the TCP framing between them and the collector (some records hold two messages glued +together). + +## Known limits + +- The playground's parser and writer plugins link go-sdk v1.1.26 and its CEL plugin v1.1.34; the + alerts module pins v1.1.33. `plugins/cel.go` and `plugins/rules.go` are identical in the three + versions, and `plugins/cel_overloads.go` is identical in v1.1.26 and v1.1.33; the predicates + were also checked with v1.1.33. Neither build is asserted to match a customer deployment. +- No history search ran: there was no OpenSearch. History, indexing, grouping, the MAC rule's + new deduplication, notifications and production alerts were not tested. The volume table above + is a model of the SDK search over stored timestamps. +- The only positive cases for the MAC and ARP rules are synthetic normalized events, because this + filter cannot give their remaining messages an address yet (D-3). +- The Go extraction test is a model of the engine's step plugins. It agreed with the playground on + every field of the 44 lines, but `replay.py` is the check that runs the engine. +- `equals("log.severity", "4")` compares numbers, like the neighbouring `oneOf` severity clauses, + so a level written `04` or `+4` now counts as 4, as `03` already counted as 3; the old clause + gave such a level no severity. No stored record has such a level. +- The separate action-result correction edits the same filter; combining the two needs a rebase + at the version line. + +## Reproduce + +Build the EventProcessor commit above without changing its dependencies. With `EP` set to that +checkout's absolute path: + +```sh +mkdir -p "$EP/test-bin" "$EP/test-plugins" +(cd "$EP" && go build -mod=readonly -o "$EP/test-bin/playground" ./cmd/playground) +for plugin in add cast cel delete grok saw sew trim; do + (cd "$EP/plugins/$plugin" && go build -mod=readonly -o "$EP/test-plugins/$plugin.plugin" .) +done +``` + +From this UTMStack checkout, with PyYAML installed: + +```sh +python3 plugins/alerts/testdata/cisco-switch/replay.py --playground "$EP/test-bin/playground" \ + --plugins "$EP/test-plugins" +(cd plugins/alerts && go test ./... -count=1) +``` + +The playground run takes about two and a half minutes. The Go suite alone does not run the +engine. From c53c531d5e3f88bf70a423249aa72fa8f97cd4a0 Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 12:35:59 -0400 Subject: [PATCH 5/7] docs(cisco-switch): record the playground positive control in the audit Add the playground run in which two contrived lines gave the committed MAC and ARP rules a true condition: both rules reached their history search with the value resolved, and both searches failed because no OpenSearch was listening. State that no history search completed. Co-Authored-By: Claude Opus 5.5 --- filters/audits/cisco-switch.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/filters/audits/cisco-switch.md b/filters/audits/cisco-switch.md index 43466485c..a6579204b 100644 --- a/filters/audits/cisco-switch.md +++ b/filters/audits/cisco-switch.md @@ -149,6 +149,7 @@ checksum. File input, one fresh private working directory per run. | Original filter, 398 inputs (327 real records from three instances, 71 fabricated) | 398 events, identical to an earlier run of the same inputs. 25 events carry the line-206 error (17 without a `log` object, 8 without a severity); on the 327 real records the playground reproduced every stored document, error texts included. | | Corrected filter, same 398 inputs | 398 events, no errors, severity identical on 398. Every difference from the original run is an intended new field: `origin.mac` 59, `log.vlan`/`log.firstPort`/`log.secondPort` 37 each, `target.ip` 21, `target.port` 13 (all numbers), `origin.ip` 9; 30 of 30 real flap records and 42 of 42 real SISF, SSH, DHCPD and logging-host records carry their fields; 18 of 18 fabricated near-misses carry none; no event has `origin.port`. | | Corrected filter and the three rules, 20 fabricated lines: 8 flaps of one address within two minutes, 2 `SW_DAI`, 1 each of SISF, SSH, DHCPD and logging host, 6 `SW_VLAN`/`DTP` | 20 events without errors. Six VLAN hopping alerts, exactly on the six `SW_VLAN`/`DTP` lines; no MAC, ARP or `Circuit Breaker` alert; no compile, rule or history search error. The rules' OpenSearch address was a closed local port and no history search was attempted. | +| Corrected filter and the three rules, 2 contrived lines: a SISF line whose text also says `duplicate mac`, and an SSH session line that ends in `gratuitous arp` | Positive control for the MAC and ARP rules: both conditions were true on the addresses the new steps wrote (`origin.mac`, `origin.ip`), and each rule reached its history search with the value resolved; both searches failed because nothing listened. No alert. These lines are not real message shapes and are not committed. | | Corrected filter and the ORIGINAL rules, same 20 lines | The original MAC rule reached its history search on all 8 flaps (their `origin.mac` now resolves); each search failed because nothing listened. Both `SW_DAI` lines failed the MAC and the ARP rule with `expression value cannot be nil after placeholder resolution`. One `Circuit Breaker: MAC Address Spoofing Detection` alert; the same six VLAN alerts. | | Committed `replay.py`, 44 lines | 44 events without errors, every stored field as recorded in `expected.json`; six alerts, all from the VLAN hopping rule; no `Circuit Breaker` and no history search attempted. | @@ -214,11 +215,13 @@ together). alerts module pins v1.1.33. `plugins/cel.go` and `plugins/rules.go` are identical in the three versions, and `plugins/cel_overloads.go` is identical in v1.1.26 and v1.1.33; the predicates were also checked with v1.1.33. Neither build is asserted to match a customer deployment. -- No history search ran: there was no OpenSearch. History, indexing, grouping, the MAC rule's - new deduplication, notifications and production alerts were not tested. The volume table above - is a model of the SDK search over stored timestamps. -- The only positive cases for the MAC and ARP rules are synthetic normalized events, because this - filter cannot give their remaining messages an address yet (D-3). +- No history search completed: there was no OpenSearch, so the searches that the original rules + and the two contrived lines started failed to connect. History, indexing, grouping, the MAC + rule's new deduplication, notifications and production alerts were not tested. The volume table + above is a model of the SDK search over stored timestamps. +- The MAC and ARP rules have no positive case in a real message shape, because this filter cannot + give their remaining messages an address yet (D-3). Their positive cases are the synthetic + normalized events and the two contrived playground lines above. - The Go extraction test is a model of the engine's step plugins. It agreed with the playground on every field of the 44 lines, but `replay.py` is the check that runs the engine. - `equals("log.severity", "4")` compares numbers, like the neighbouring `oneOf` severity clauses, From 9b40db0650013f20fc182da8ca2bfa69fb5b9e75 Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 16:19:02 -0400 Subject: [PATCH 6/7] docs(cisco-switch): record re-validation on the latest v11, go-sdk and engine Official v11 d2479c1a (go-sdk v1.1.36 in plugins/alerts) and EventProcessor main 8a3ade7 (every playground plugin on v1.1.36). Field names now keep underscores and regexMatch matches strings only; neither changes this draft, because none of the 25 names the filter writes contains an underscore and every text-search call reads a string field. Full plugins/alerts suite: 51 pass, 11 skip, 0 fail. replay.py: 44 events, 6 VLAN alerts, no Circuit Breaker, no history search. The 398 private inputs and the 2,823 distinct texts give event-for-event the same output as the original review; rule runs 14 of 14; the original rules still trip the Circuit Breaker. v1.1.36 replay: committed MAC and ARP rules match none of 229,017 real records; 15 of 15 synthetic checks. Co-Authored-By: Claude Opus 5.5 --- filters/audits/cisco-switch.md | 66 +++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/filters/audits/cisco-switch.md b/filters/audits/cisco-switch.md index a6579204b..a26ab1e91 100644 --- a/filters/audits/cisco-switch.md +++ b/filters/audits/cisco-switch.md @@ -8,7 +8,10 @@ raised `Circuit Breaker` alerts in production; it has never produced a detection poisoning rule had the same unresolved history value on every branch. The one raw `log.*` comparison in the filter stored an error on every line without a Cisco header. Addresses that six real message types carry in their text were never mapped. This revision fixes those points and -nothing else. The schema is ThreatWinds go-sdk **v1.1.33**, as pinned by `plugins/alerts/go.mod`. +nothing else. The schema is ThreatWinds go-sdk **v1.1.36**, as pinned by `plugins/alerts/go.mod` +since official `v11` (`d2479c1a`) was merged into this branch. The review itself used v1.1.33, +whose `plugins.proto`, `plugins/cel.go` and `plugins/rules.go` are identical. The draft was +checked again on the latest versions; see [Re-validation on the latest versions](#re-validation-on-the-latest-versions). ## Evidence basis @@ -60,8 +63,11 @@ nothing else. The schema is ThreatWinds go-sdk **v1.1.33**, as pinned by `plugin [grok plugin](https://github.com/utmstack/EventProcessor/blob/497bf53dbd1ae096f7b2dbc7bce77a6bf9f22ce1/plugins/grok/main.go) writes nothing unless every pattern matched at the start of the remaining text, and whose [CEL plugin](https://github.com/utmstack/EventProcessor/blob/497bf53dbd1ae096f7b2dbc7bce77a6bf9f22ce1/plugins/cel/main.go) - disables a rule at its fifth error with a `Circuit Breaker: ` alert; and go-sdk - v1.1.33, whose [`plugins/cel.go`](https://github.com/threatwinds/go-sdk/blob/v1.1.33/plugins/cel.go) + disables a rule at its fifth error with a `Circuit Breaker: ` alert (the latest + EventProcessor, `main` at `8a3ade72bd9d12db21f6b273200588fb49540f14`, changes only these + plugins' go-sdk version and how the CEL plugin reads its OpenSearch address, so this is the + same there); and go-sdk v1.1.33 (the three files below are identical in v1.1.36), whose + [`plugins/cel.go`](https://github.com/threatwinds/go-sdk/blob/v1.1.33/plugins/cel.go) declares a CEL variable only for the top-level keys an event has (so `log.severity=="4"` fails without a `log` object, while `equals` returns false), whose [`plugins/rules.go`](https://github.com/threatwinds/go-sdk/blob/v1.1.33/plugins/rules.go) @@ -128,8 +134,50 @@ With O4 the rule keeps its duplicate-MAC, MAC-conflict, MAC-move and `SW_DAI` br need an `origin.mac` producer for those messages (D-3); on today's data it has no live path, as before, but it no longer disables itself. +## Re-validation on the latest versions + +On 2026-09-24 official `v11` moved to `d2479c1a3705eec6a00016689c2bf5fbcc1814f2`, whose +`plugins/alerts` pins go-sdk v1.1.36, and EventProcessor `main` moved to +`8a3ade72bd9d12db21f6b273200588fb49540f14`, whose playground and parser, writer and CEL +plugins all link go-sdk v1.1.36. `v11` was merged into this branch. No file overlaps this +draft, so nothing conflicted. + +What changed in the SDK, and what it means here: + +- Since v1.1.35, `utils.SanitizeField` keeps `_` in the field names that the `json` + (top-level keys), `kv`, `grok`, `csv`, `xml`, `add` and `rename` plugins write. Other + characters are still removed. This filter has no `json`, `kv` or `csv` step, and none of + the 25 names it writes contains `_` or another removed character. The underscores in + `SW_MATM`, `MACFLAP_NOTIF` and similar are values, not names. So every stored name stays the + same; the comparisons below confirm it on real records. +- v1.1.36 makes `regexMatch` match string values only again. Since v1.1.34, `contains`, + `containsAll`, `startsWith` and `endsWith` also search the JSON text of objects and lists. + Every such call in this filter and its rules reads a text field (`log.msg`, `log.message`), + so no result changes. `plugins.proto`, `plugins/cel.go` and `plugins/rules.go` are identical + in v1.1.33 and v1.1.36. +- No filter, rule or fixture needed a change. + +| Check on the latest versions | Result | +|---|---| +| Full `plugins/alerts` suite, go-sdk v1.1.36 | 51 tests pass, 11 skip, none fail (2,281 passing results with subtests). The eight Cisco switch tests pass; the Go model of the step plugins uses the SDK's own `SanitizeField`, so it follows v1.1.36. In a throwaway copy with a Cisco switch manifest added, `TestFilterAndRuleContracts` passes for the filter and the three rules (312 subtests, none fail). The skipped tests need other technologies' private evidence and skip on the base commit too. | +| `replay.py` on EventProcessor 8a3ade7 | 44 events without errors, every stored field as in `expected.json`; six alerts, all from the VLAN hopping rule; no `Circuit Breaker` and no history search attempted. | +| Original and corrected filter, the same 398 inputs (327 real records, 71 fabricated) | Both runs are identical, event by event, to the original review's runs (with the new port names). Errors 25 to 0; severity identical on 398; 30 of 30 real flap records and 42 of 42 real SISF, SSH, DHCPD and logging-host records carry their fields; 18 of 18 fabricated near-misses carry none; `target.port` is a number on all 13; no event has `origin.port`. | +| Corrected filter, the 2,795 distinct real texts and 28 fabricated lines | 2,823 events, identical to the original review's output for every line. | +| Corrected filter and the three rules, the same 20 fabricated lines | Six VLAN hopping alerts, exactly on the six `SW_VLAN`/`DTP` lines; no MAC, ARP or `Circuit Breaker` alert; no rule or history search error. 14 of 14 checks pass. | +| Positive control, the same 2 contrived lines | Both the MAC and the ARP rule reached their history search with the value resolved; both searches failed because nothing listened. No alert. | +| Corrected filter and the original rules, the same 20 lines | The original MAC rule reached its history search on all 8 flaps; both `SW_DAI` lines failed the MAC and ARP rules with `expression value cannot be nil after placeholder resolution`; one `Circuit Breaker: MAC Address Spoofing Detection` alert. | +| go-sdk v1.1.36 rule replay | Over the 2,823 latest outputs above plus the 8 synthetic events: the committed MAC and ARP rules match none of the 229,017 real records they stand for; the original MAC rule matches all 182,326 flap records with the value resolved; 15 of 15 synthetic checks pass. Over the 398 latest events: committed MAC 0, ARP 0, VLAN 1 fabricated line. | + +At 8a3ade7 the CEL plugin reads its OpenSearch address from separate `host`, `port`, `user` +and `password` settings. `replay.py` still gives one URL, so the client gets an empty host +and connects to port 443 on the test computer, where nothing listened. Any history search +therefore still fails and is reported; none was attempted with the committed rules and lines. + ## Validation +These are the original review's results, on EventProcessor `497bf53` and go-sdk v1.1.33. +The section above repeats them on the latest versions. + **Fabricated regression, committed.** `plugins/alerts/testdata/cisco-switch/` holds 44 invented raw lines (`raw.json`), their expected fields and alerts (`expected.json`), the 8 shared grok definitions the filter uses (`patterns.yaml`, copied from @@ -211,10 +259,10 @@ together). ## Known limits -- The playground's parser and writer plugins link go-sdk v1.1.26 and its CEL plugin v1.1.34; the - alerts module pins v1.1.33. `plugins/cel.go` and `plugins/rules.go` are identical in the three - versions, and `plugins/cel_overloads.go` is identical in v1.1.26 and v1.1.33; the predicates - were also checked with v1.1.33. Neither build is asserted to match a customer deployment. +- The latest check used EventProcessor `8a3ade7`, whose playground and plugins link go-sdk + v1.1.36, the version the alerts module now pins; the predicates were also checked with + v1.1.36. The original review used `497bf53` (parser and writer plugins v1.1.26, CEL plugin + v1.1.34) and v1.1.33 predicates. Neither build is asserted to match a customer deployment. - No history search completed: there was no OpenSearch, so the searches that the original rules and the two contrived lines started failed to connect. History, indexing, grouping, the MAC rule's new deduplication, notifications and production alerts were not tested. The volume table @@ -232,8 +280,8 @@ together). ## Reproduce -Build the EventProcessor commit above without changing its dependencies. With `EP` set to that -checkout's absolute path: +Build EventProcessor `8a3ade72bd9d12db21f6b273200588fb49540f14` (the latest check) without +changing its dependencies. With `EP` set to that checkout's absolute path: ```sh mkdir -p "$EP/test-bin" "$EP/test-plugins" From 6aa67c4d0396af7633628a0250503d26bd95d687 Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Thu, 24 Sep 2026 17:07:30 -0400 Subject: [PATCH 7/7] docs(cisco-switch): name the newest engine image behind the re-validation build The newest published engine image, eventprocessor:v11.2.14 (built 2026-09-24 19:13 UTC), carries EventProcessor revision 8a3ade7 with go-sdk v1.1.36, built with go1.26.8 for linux/amd64. The local build used for the re-validation is the same source compiled natively for darwin/arm64 with go1.25.7. Co-Authored-By: Claude Opus 5.5 --- filters/audits/cisco-switch.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/filters/audits/cisco-switch.md b/filters/audits/cisco-switch.md index a26ab1e91..27025ea37 100644 --- a/filters/audits/cisco-switch.md +++ b/filters/audits/cisco-switch.md @@ -142,6 +142,13 @@ On 2026-09-24 official `v11` moved to `d2479c1a3705eec6a00016689c2bf5fbcc1814f2` plugins all link go-sdk v1.1.36. `v11` was merged into this branch. No file overlaps this draft, so nothing conflicted. +The newest published engine image, `ghcr.io/utmstack/utmstack/eventprocessor:v11.2.14` +(built 2026-09-24 19:13 UTC on base image `eventprocessor/base:1.1.7`), embeds Go build +information showing that its playground and plugin binaries come from the same +EventProcessor revision `8a3ade7` with go-sdk v1.1.36, built with go1.26.8 for linux/amd64. +The local build used here is that source revision compiled natively for darwin/arm64 with +go1.25.7; only the Go toolchain and platform differ. + What changed in the SDK, and what it means here: - Since v1.1.35, `utils.SanitizeField` keeps `_` in the field names that the `json`