From bd3b7557f87bc1ba8ab55d68b3de5546934e711c Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Tue, 29 Sep 2026 19:03:28 -0400 Subject: [PATCH] fix(macos): stop XProtect's own rule loading from raising alerts "XProtect Evasion or Tampering Detected" excludes XProtect's own service by name, but macOS logs it as XprotectService and the check was case-sensitive, so every time the service loaded its rules ("Using XProtect rules location: ... XProtect.yara") the rule alerted, and groupBy stored a child alert for each record. The service is now recognised whatever the letter case of its name, and the rule raises one alert per Mac and process, dropping repeats for seven days (deduplicateBy dataSource, adversary.process). macos_alert_volume_test.go pins both; macCheckGrouping now also reads deduplicateBy and accepts dataSource as the source identity. Co-Authored-By: Claude Opus 5.5 --- plugins/alerts/macos_alert_volume_test.go | 74 +++++++++++++++++++++++ plugins/alerts/macos_contract_test.go | 8 ++- rules/macos/xprotect_evasion.yml | 10 +-- 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 plugins/alerts/macos_alert_volume_test.go diff --git a/plugins/alerts/macos_alert_volume_test.go b/plugins/alerts/macos_alert_volume_test.go new file mode 100644 index 000000000..b1600782e --- /dev/null +++ b/plugins/alerts/macos_alert_volume_test.go @@ -0,0 +1,74 @@ +package main + +// Fabricated macOS unified-log records run through the offline macOS parser +// model (macParse) and the pinned SDK CEL. They pin the de-duplication keys of +// the XProtect rule and that XProtect's own service is recognised whatever the +// letter case of its name. The EventProcessor playground separately runs the +// real parser and alert plugins. +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/threatwinds/go-sdk/plugins" + "github.com/tidwall/gjson" +) + +func macVolumeRaw(t *testing.T, process, subsystem, message string) string { + t.Helper() + event := map[string]any{ + "timestamp": "2026-09-29T10:00:00Z", "process": process, "process_identifier": 123, "thread_identifier": 456, + "activity_identifier": 0, "class_name": "OSLogEntryLog", "level": "default", "message": message, + "store_category": "undefined", "sender": process, + } + if subsystem != "" { + event["subsystem"] = subsystem + event["category"] = "xprotect" + } + b, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestMacOSXProtectAlertVolume(t *testing.T) { + cfg, cache := macConfig(t), plugins.NewCELCache("macos-alert-volume") + r := macRules(t)["xprotect_evasion"] + if r == nil { + t.Fatal("missing xprotect_evasion") + } + if want := []string{"dataSource", "adversary.process"}; len(r.GroupBy) != 0 || !reflect.DeepEqual(r.DeduplicateBy, want) { + t.Fatalf("grouping got groupBy %v deduplicateBy %v, want deduplicateBy %v", r.GroupBy, r.DeduplicateBy, want) + } + const rules = "Using XProtect rules location: /var/protected/xprotect/XProtect.bundle/Contents/Resources/XProtect.yara" + for _, tc := range []struct { + name string + raw string + want bool + }{ + // XProtect's own service loading its rules, in the spelling macOS logs and the documented one. + {"XprotectService loads its rules", macVolumeRaw(t, "XprotectService", "com.apple.xprotect", rules), false}, + {"XProtectService loads its rules", macVolumeRaw(t, "XProtectService", "com.apple.xprotect", rules), false}, + {"another process touches the rules", macVolumeRaw(t, "bash", "", "cp /tmp/x /Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.yara"), true}, + {"a process deletes the rules", macVolumeRaw(t, "XprotectService", "com.apple.xprotect", "delete /var/protected/xprotect/XProtect.bundle/Contents/Resources/XProtect.yara"), true}, + {"MRT terminated", macVolumeRaw(t, "MRT", "", "terminate"), true}, + } { + t.Run(tc.name, func(t *testing.T) { + out := macParse(t, cfg, tc.raw, "mac-lab", cache) + got, err := cache.Eval(r.Where, out) + if err != nil || got != tc.want { + t.Fatalf("where got %v (%v), want %v for %s", got, err, tc.want, out) + } + if !tc.want { + return + } + // adversary: origin, so adversary.process is the event's origin.process. + for key, path := range map[string]string{"dataSource": "dataSource", "adversary.process": "origin.process"} { + if v := gjson.Get(out, path); v.Type != gjson.String || v.String() == "" { + t.Fatalf("de-duplication key %s (%s) does not resolve in %s", key, path, out) + } + } + }) + } +} diff --git a/plugins/alerts/macos_contract_test.go b/plugins/alerts/macos_contract_test.go index 91c9c312f..7f9196e73 100644 --- a/plugins/alerts/macos_contract_test.go +++ b/plugins/alerts/macos_contract_test.go @@ -416,10 +416,14 @@ func macCheckGrouping(t *testing.T, rule *plugins.Rule, eventJSON string) { t.Fatal(e) } hasIdentity := false - for _, field := range rule.GroupBy { + for _, field := range append(append([]string{}, rule.GroupBy...), rule.DeduplicateBy...) { path := strings.Replace(field, "lastEvent.", "events.0.", 1) value := gjson.Get(*wire, path) - if field == "adversary.host" || field == "lastEvent.dataSource" { + if field == "dataSource" { + // The alert plugin copies the event's dataSource onto the alert. + value = gjson.Get(*wire, "events.0.dataSource") + } + if field == "adversary.host" || field == "lastEvent.dataSource" || field == "dataSource" { if value.String() != event.DataSource || value.String() == "" || value.String() == "unknown" { t.Errorf("%s has no usable grouping identity %s", rule.Name, field) } diff --git a/rules/macos/xprotect_evasion.yml b/rules/macos/xprotect_evasion.yml index c5d2d0e3a..4fc0d1d85 100644 --- a/rules/macos/xprotect_evasion.yml +++ b/rules/macos/xprotect_evasion.yml @@ -1,4 +1,4 @@ -# Rule version v1.0.0 +# Rule version v1.1.0 dataTypes: - macos @@ -14,7 +14,7 @@ references: - https://www.sentinelone.com/blog/macos-malware-researchers-how-to-bypass-xprotect-on-catalina/ - https://attack.mitre.org/techniques/T1562/001/ description: | - Detects attempts to evade or tamper with XProtect malware detection including modification of XProtect files, databases, or YARA rules. XProtect is Apple's built-in antimalware system, and attempts to bypass or disable it indicate potential malicious activity. + Detects attempts to evade or tamper with XProtect malware detection including modification of XProtect files, databases, or YARA rules. XProtect is Apple's built-in antimalware system, and attempts to bypass or disable it indicate potential malicious activity. XProtect's own service is recognised whatever the letter case of its name (macOS logs it as XprotectService). One alert is raised per Mac and process; repeats are suppressed for seven days. Next Steps: 1. Immediately investigate the affected system and user account for signs of compromise @@ -42,7 +42,7 @@ where: | regexMatch("log.message", ".*XProtect\\.(yara|meta\\.plist|bundle).*") && ( regexMatch("log.message", ".*(modify|tamper|delete).*") || - (exists("origin.process") && !equals("origin.process", "XProtectService")) + (exists("origin.process") && !equalsIgnoreCase("origin.process", "XProtectService")) ) ) || ( @@ -57,6 +57,6 @@ where: | equals("log.subsystem", "com.apple.MRT") && (contains("log.message", "bypass") || contains("log.message", "disable") || contains("log.message", "fail")) ) -groupBy: +deduplicateBy: + - dataSource - adversary.process - - adversary.host