Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions plugins/alerts/macos_alert_volume_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
8 changes: 6 additions & 2 deletions plugins/alerts/macos_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
10 changes: 5 additions & 5 deletions rules/macos/xprotect_evasion.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Rule version v1.0.0
# Rule version v1.1.0

dataTypes:
- macos
Expand All @@ -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
Expand Down Expand Up @@ -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"))
)
) ||
(
Expand All @@ -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
Loading