HPE Morpheus Enterprise writes its audit trail through two separate internal loggers, and you need both of them. What follows is the config we run to produce those logs and forward them to a SIEM as CEF over syslog, with no plugin, no scheduled job and no API polling involved.
Tested on Morpheus 9.0.x and 8.1.x, RHEL 8, forwarding into QRadar.
Know which logger writes what
The documented CEF export gets you a working audit feed with this logger:
<logger name="com.morpheus.AuditLogService" level="INFO" additivity="false">
<appender-ref ref="AUDIT" />
</logger>
That's the right starting point, and for a lot of use cases it's enough on its own. It's worth being precise about what it covers, though: AuditLogService produces audit.log, the HTTP/controller-level audit trail. The activity records you see in the Morpheus Activity view come from a different class, com.morpheus.ActivityService, and they don't appear in that file.
Both loggers are in the product's logger list. They just cover different ground, and the difference matters as soon as you start writing correlation rules.
What each one covers
Somebody creates a privileged user. Here is what the audit log records:
CEF:0|MorpheusData|Morpheus|9.0.1|admin:users#save|user Created|0|
src=10.10.20.31 suid=2 suser=jsmith
request=https://morpheus.example.com/admin/users requestMethod=POST
A named user, a known IP, a POST to the users endpoint, and a user was created. What this line doesn't carry is which user, and with what privileges.
For most operational purposes that's fine. For a SOC it isn't, because creating an extra admin account is a routine persistence step after a credential compromise, and this is the event you'd most want fully described.
The detail is in the other log:
objectType=User, objectId=32, name=svc-backup,
message=User created with roles 'System Admin'., userName=jsmith
svc-backup, with System Admin. No source IP here, though, and that's by design rather than an artefact of the log format: the /api/activity schema is _id, success, activityType, name, message, objectType, objectId, user, ts. Activity records track what changed, not where the call came from.
So neither log answers the question on its own, which is where the two-feed design comes from.
AuditLogService |
ActivityService |
|
|---|---|---|
| covers | controller actions: login, logout, API calls | object create/update/delete, provisioning, backups |
| source IP | yes | no |
| object identity | only on update and delete | yes |
| background events | no | yes |
Two things follow from that table. Creates are where the two feeds differ most, so a test that only deletes a user won't show you the difference. And since the audit feed is written from controller actions, work that isn't driven by an HTTP request sits in the activity feed instead: provisioning completion, backup jobs, alerts.
That leaves us with two log files, two forwarders and two log sources in the SIEM.
What you'll edit
| file | purpose |
|---|---|
/opt/morpheus/conf/logback.xml |
make Morpheus write the two log files |
/etc/rsyslog.d/59-morpheus-imfile.conf |
enable rsyslog's file-tailing module |
/etc/rsyslog.d/60-morpheus-audit.conf |
forward audit.log
|
/etc/rsyslog.d/61-morpheus-activity.conf |
forward activity.log
|
Do logback first; the log files don't exist until Morpheus creates them. Everything is per-node, so repeat on every UI node.
Two placeholders to replace throughout: 10.0.0.50 is the SIEM collector, Europe/Istanbul is the timezone.
Step 1 — logback
1.1 Back up the file. A malformed logback.xml stops morpheus-ui from starting, so this is your way back.
cp /opt/morpheus/conf/logback.xml{,.bak.$(date +%F)}
1.2 Check the masking rule is there. Near the top of the file, among a few <conversionRule> lines:
<conversionRule conversionWord="maskedMsg"
converterClass="com.morpheus.LogbackMaskingConverter" />
The patterns below use it and it redacts secrets. Leave all the <conversionRule> lines exactly where they are.
1.3 Add the two appenders. Paste them after the last existing <appender> block, before the first <logger>. The docs call this out too: a logger placed above the appender it references will throw an error on startup.
<appender name="AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/morpheus/morpheus-ui/audit.log</file>
<immediateFlush>true</immediateFlush>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/var/log/morpheus/morpheus-ui/audit.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>90</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>[%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX, Europe/Istanbul}] [%thread] %-5level %logger{15} - %maskedMsg%n</pattern>
</encoder>
</appender>
<appender name="ACTIVITY" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/morpheus/morpheus-ui/activity.log</file>
<immediateFlush>true</immediateFlush>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/var/log/morpheus/morpheus-ui/activity.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>90</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>end=%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX, Europe/Istanbul} msg=%maskedMsg%n</pattern>
</encoder>
</appender>
1.4 Add the two loggers. These go among the existing <logger> elements, anywhere in that block:
<logger name="com.morpheus.AuditLogService" level="INFO" additivity="false">
<appender-ref ref="AUDIT" />
</logger>
<logger name="com.morpheus.ActivityService" level="DEBUG" additivity="false">
<appender-ref ref="ACTIVITY" />
</logger>
You'll already have a blanket <logger name="com.morpheus" level="INFO"/> in there. Leave it. logback picks the most specific match, so the two above win for their own classes.
The documented example is a deliberately minimal one. Three things we changed for production use, and why:
An absolute fileNamePattern. logback resolves a relative path against the process working directory rather than against <file>, so with the short form the rolled files land somewhere rsyslog isn't tailing.
An explicit date format instead of bare %d, which emits 2026-08-06 10:12:33,123 with no timezone offset. A collector that has to infer the zone will get it wrong for somebody.
<charset>UTF-8</charset> and <totalSizeCap> added. The first matters the moment a tenant or user name isn't ASCII; the second is what keeps /var/log from filling up.
Three more details are worth understanding rather than just copying.
The timezone has to be pinned, and it has to be the same string in both patterns. The morpheus-ui JVM runs in UTC, so without an explicit zone XXX prints whatever the JVM default happens to be, and you can easily end up with one appender writing +03:00 while the other writes Z for the same instant. Nothing errors when that happens. The two feeds just quietly stop correlating, which is a miserable thing to discover three months later.
The levels aren't interchangeable either. AuditLogService at DEBUG adds nothing at all, since its content is decided in Morpheus code rather than in your pattern, while ActivityService really does need DEBUG. Whatever you do, don't set the blanket com.morpheus logger to DEBUG to be safe. That's gigabytes a day.
immediateFlush matters because rsyslog tails these files and shouldn't be waiting on a buffer.
1.5 Restart the UI. New appenders always need a restart; scanPeriod only picks up level changes.
morpheus-ctl stop morpheus-ui
morpheus-ctl start morpheus-ui
morpheus-ctl tail morpheus-ui # wait for the UI to come up
If it doesn't come back, restore your backup and restart again. The cause is almost always a paste in the wrong place or an XML comment containing --, which logback won't parse.
1.6 Verify before going further. Log into the Morpheus UI, then create a test user. Both files should now exist and have content:
tail -2 /var/log/morpheus/morpheus-ui/audit.log
tail -2 /var/log/morpheus/morpheus-ui/activity.log
You want a CEF line in the first and a msg=results: {...} line in the second. Check the timestamps carry the offset you configured, not Z.
Don't move on until both files have data. Everything from here just moves those lines onto the network, so if something is wrong it's easier to find now than after rsyslog is in the picture.
Step 2 — rsyslog
2.1 Check whether imfile is already loaded.
grep -r 'load="imfile"' /etc/rsyslog.conf /etc/rsyslog.d/
grep -r 'workDirectory' /etc/rsyslog.conf
If the first command returns nothing, create /etc/rsyslog.d/59-morpheus-imfile.conf. This line is straight out of the Morpheus docs' own syslog-forwarding example:
module(load="imfile" PollingInterval="10")
If it returns a match, skip that file. A second module(load="imfile") anywhere in the config is a hard error, not a warning, and it takes the whole config down with it.
The second command should show global(workDirectory="/var/lib/rsyslog"), which is the RHEL default. That's where rsyslog remembers its position in each file. If it's missing, add it.
2.2 Create /etc/rsyslog.d/60-morpheus-audit.conf.
These records are already CEF, so this file reframes them: strip the logback prefix, take the event time from it, add rt= and end=.
input(type="imfile"
File="/var/log/morpheus/morpheus-ui/audit.log"
Tag="morpheus-audit:"
Severity="info"
Facility="local6"
ruleset="morpheus_audit_to_siem"
PersistStateInterval="100"
reopenOnTruncate="on"
freshStartTail="on")
template(name="MorpheusAuditCEF" type="list") {
constant(value="<109>1 ")
property(name="timegenerated" dateFormat="rfc3339")
constant(value=" ")
property(name="$myhostname")
constant(value=" morpheus-audit Morpheus - - - ")
property(name="$!cefhead")
constant(value="rt=")
property(name="timegenerated" dateFormat="unixtimestamp")
constant(value="000 end=")
property(name="$!end")
constant(value=" ")
property(name="$!cefext")
constant(value="\n")
}
ruleset(name="morpheus_audit_to_siem") {
if not ($msg contains "CEF:0|") then { stop }
set $!end = re_extract($msg, "^[[]([0-9T:.+-]+)[]]", 0, 1, "");
set $!cefhead = re_extract($msg, "(CEF:0[|][^|]*[|][^|]*[|][^|]*[|][^|]*[|][^|]*[|][^|]*[|])", 0, 1, "");
set $!cefext = re_extract($msg, "CEF:0[|][^|]*[|][^|]*[|][^|]*[|][^|]*[|][^|]*[|][^|]*[|](.*)", 0, 1, "");
if $!cefhead == "" then {
action(type="omfwd"
target="10.0.0.50"
port="514"
protocol="udp"
template="RSYSLOG_SyslogProtocol23Format"
queue.type="LinkedList"
queue.filename="morpheus_audit_siem_raw_q"
action.resumeRetryCount="-1")
stop
}
action(type="omfwd"
target="10.0.0.50"
port="514"
protocol="udp"
template="MorpheusAuditCEF"
queue.type="LinkedList"
queue.size="10000"
queue.dequeueBatchSize="100"
queue.workerThreads="2"
queue.saveOnShutdown="on"
queue.filename="morpheus_audit_siem_q"
action.resumeRetryCount="-1"
action.resumeInterval="30")
stop
}
Anything that doesn't parse as CEF still goes out, verbatim, through a second queue. Dropping it would have been simpler, but a line the SOC can see and alert on is better than one that vanishes without trace.
2.3 Create /etc/rsyslog.d/61-morpheus-activity.conf.
ActivityService writes a Groovy map, not JSON, so this one rebuilds the CEF from extracted fields. Put your own Morpheus version in the header; rsyslog can't discover it.
input(type="imfile"
File="/var/log/morpheus/morpheus-ui/activity.log"
Tag="morpheus-activity:"
Severity="info"
Facility="local6"
ruleset="morpheus_activity_to_siem"
PersistStateInterval="100"
reopenOnTruncate="on"
freshStartTail="on")
template(name="MorpheusActivityCEF" type="list") {
constant(value="<")
property(name="$!pri")
constant(value=">1 ")
property(name="timegenerated" dateFormat="rfc3339")
constant(value=" ")
property(name="$myhostname")
constant(value=" morpheus-activity Morpheus - - - CEF:0|HPE|Morpheus|9.0.1|")
property(name="$!act")
constant(value="|")
property(name="$!act")
constant(value=" ")
property(name="$!objectType")
constant(value="|")
property(name="$!sev")
constant(value="|rt=")
property(name="timegenerated" dateFormat="unixtimestamp")
constant(value="000 end=")
property(name="$!end")
constant(value=" suser=")
property(name="$!suser")
constant(value=" suid=")
property(name="$!suid")
constant(value=" act=")
property(name="$!act")
constant(value=" outcome=")
property(name="$!outcome")
constant(value=" cs1Label=Object Type cs1=")
property(name="$!objectType" caseConversion="lower")
constant(value=" cn1Label=Object ID cn1=")
property(name="$!objectId")
constant(value=" cs2Label=ActivityId cs2=")
property(name="$!activityId")
constant(value=" cs3Label=ObjectName cs3=")
property(name="$!objectName")
constant(value=" msg=")
property(name="$!msgtext")
constant(value="\n")
}
ruleset(name="morpheus_activity_to_siem") {
if ($msg contains "activity query") or ($msg contains "activity template results") then { stop }
if not ($msg contains "msg=results: {") then { stop }
set $!activityId = re_extract($msg, "id=([0-9a-fA-F-]{36})", 0, 1, "");
set $!ok = re_extract($msg, "document=[{]success=(true|false)", 0, 1, "true");
set $!end = re_extract($msg, "end=([^ ]+) msg=", 0, 1, "");
set $!objectName = re_extract($msg, ", name=([^,}]*)", 0, 1, "");
set $!act = re_extract($msg, "activityType=([^,}]+)", 0, 1, "Activity");
set $!suid = re_extract($msg, "userId=([0-9]+)", 0, 1, "");
set $!suser = re_extract($msg, "userName=([^,}]*)", 0, 1, "");
set $!objectId = re_extract($msg, "objectId=([0-9]+)", 0, 1, "");
set $!objectType = re_extract($msg, "objectType=([^,}]+)", 0, 1, "");
set $!m0 = re_extract($msg, "message=(.*), ts=", 0, 1, "");
set $!m1 = re_extract($!m0, "^(.*), [a-zA-Z]+=[^,]*\$", 0, 1, $!m0);
set $!m2 = re_extract($!m1, "^(.*), [a-zA-Z]+=[^,]*\$", 0, 1, $!m1);
set $!msgtext = re_extract($!m2, "^(.*), [a-zA-Z]+=[^,]*\$", 0, 1, $!m2);
if $!ok == "true" then {
set $!outcome = "success";
set $!sev = "3";
set $!pri = "109";
} else {
set $!outcome = "failure";
set $!sev = "7";
set $!pri = "108";
}
action(type="omfwd"
target="10.0.0.50"
port="514"
protocol="udp"
template="MorpheusActivityCEF"
queue.type="LinkedList"
queue.size="10000"
queue.dequeueBatchSize="100"
queue.workerThreads="2"
queue.saveOnShutdown="on"
queue.filename="morpheus_activity_siem_q"
action.resumeRetryCount="-1"
action.resumeInterval="30")
stop
}
The two stop rules at the top drop ActivityService's own noise: it logs the UI's Elasticsearch poll every 30 seconds. Those lines stay in the file, they just don't go on the wire.
2.4 Validate, then restart. Validate the whole config, not a single drop-in. On its own a drop-in reports a missing imfile module that is actually loaded elsewhere.
rsyslogd -N1 -f /etc/rsyslog.conf
systemctl restart rsyslog
2.5 Watch the wire and generate an event. Both inputs use freshStartTail="on", so rsyslog starts at the end of each file. Nothing is sent until something new happens, which means an empty tcpdump at this point is expected until you act in the UI.
In one terminal:
tcpdump -ni any udp port 514 -A | grep morpheus-
In the Morpheus UI: log in, then create a user. Two packets should appear, one per feed. Test with a create, not a delete: a delete looks fine on the audit feed alone and hides the gap this setup exists for.
If nothing arrives, in this order:
journalctl -u rsyslog --since "5 min ago" # module and template errors
ls -l /var/lib/rsyslog/ # state files should appear
ausearch -m avc -ts recent | grep syslogd # SELinux, if the paths were relocated
If the log files themselves are empty, the problem is in Step 1, not here.
What you should be seeing
At this point the appliance side is done. Here is the same delete event as it leaves the host, through both feeds:
<109>1 2026-08-06T11:15:33.591+03:00 morpheus-ui-01 morpheus-audit Morpheus - - - CEF:0|MorpheusData|Morpheus|9.0.1|admin:users#delete|user Deleted|0|rt=1786004133000 end=2026-08-06T10:52:41.360+03:00 src=10.10.20.31 suid=2 suser=jsmith request=https://morpheus.example.com/admin/users/71 requestMethod=DELETE cn1=71 cn1Label=Object ID cs1Label=Object Type cs1=user
<109>1 2026-08-06T11:15:33.592+03:00 morpheus-ui-01 morpheus-activity Morpheus - - - CEF:0|HPE|Morpheus|9.0.1|Admin|Admin User|3|rt=1786004133000 end=2026-08-06T10:52:41.376+03:00 suser=jsmith suid=2 act=Admin outcome=success cs1Label=Object Type cs1=user cn1Label=Object ID cn1=71 cs2Label=ActivityId cs2=007611d1-9ba2-40f6-ba99-304068d32afb cs3Label=ObjectName cs3=testuser msg=User 'testuser' deleted.
Both carry two timestamps. rt= is when rsyslog picked the line up, which is what QRadar uses as the event time. end= is the real event time from logback, and it's the one to correlate on.
Step 3 — SIEM log sources
Create two log sources, both Universal CEF, one per token (morpheus-audit and morpheus-activity in the configs above). If the SIEM is another team's, this section and the next are what to hand them.
Two log sources, not one, because the feeds don't share CEF semantics: different vendor strings, and audit severity is always 0 while activity uses 3 and 7. A single log source can't map severity correctly for both.
Step 4 — repeat on every UI node
Both logback.xml and the rsyslog drop-ins are per-node. On an HA appliance, do one node at a time so the UI stays available. The hostname in each frame comes from $myhostname, so events stay attributable to the node they came from.
Check what it resolves to first:
hostname -s
If that returns an FQDN and you want the short form, pin it with global(localHostname="morpheus-ui-01").
Correlating the feeds
suser is the only field in both feeds. Join on suser, tie-break with end= in a ±2 second window, and narrow with cs1 + cn1 where they exist. Expect about 16 ms of skew.
On updates and deletes the audit feed gives you cn1, which makes the match nearly unique. On creates it doesn't, so user plus a time window is all you have to work with. That's the case the whole setup exists for: a "privileged account created" rule has to read the object name and roles from the activity event and the source IP from the audit event, and neither half is any use alone.
Spell the shared fields identically in both feeds. Morpheus writes cn1Label=Object ID and cs1=user in lower case and you can't change that side, so the activity template matches it, caseConversion="lower" included. Get this wrong and a rule matching cs1="User" will fire happily on one feed while never once matching the other, with nothing to indicate why.
Before you rely on it
Don't map severity on the audit feed. AuditLogService emits CEF severity 0 on every line it writes, success or failure alike, and the actual outcome sits in the CEF name instead: user Deleted versus user Failed to update. Map severity there and a failed privilege escalation becomes an informational event.
The bigger caveat is that the activity feed rests on an undocumented debug line. As far as I can tell it's a plain log.debug of a Groovy map, not an interface anyone promised to keep, so an upgrade could change or remove it and nothing would error. The file would go quiet, rsyslog would carry on tailing it, and the SIEM would show no activity events at all. That looks identical to a quiet week. We check the line shape after every Morpheus upgrade, and if you want a belt-and-braces version, keep an /api/activity exporter on disk and disabled as a documented fallback.
One smaller thing: exclude suser=apiuser from your correlation rules. The appliance's own token flow logs Unknown User Login Attempt a few times every five minutes, and a SIEM will read that as brute force against your management plane.
Rollback
Nothing here changes Morpheus behaviour, so backing out is just undoing the two steps.
rm /etc/rsyslog.d/59-morpheus-imfile.conf \
/etc/rsyslog.d/60-morpheus-audit.conf \
/etc/rsyslog.d/61-morpheus-activity.conf
systemctl restart rsyslog
cp /opt/morpheus/conf/logback.xml.bak.YYYY-MM-DD /opt/morpheus/conf/logback.xml
morpheus-ctl stop morpheus-ui && morpheus-ctl start morpheus-ui
The log files stay on disk. Delete them by hand if you want the space back.
Done checklist
- [ ]
logback.xmlbacked up - [ ] Timezone replaced, identical in both patterns
- [ ] SIEM address replaced in both rsyslog files
- [ ] Morpheus version set in the activity template
- [ ] Both log files exist and are being written
- [ ] Timestamps show an offset, not
Z - [ ]
rsyslogd -N1 -f /etc/rsyslog.confclean - [ ]
imfileloaded exactly once - [ ]
tcpdumpshows two packets after creating a user - [ ] Two log sources in the SIEM, no severity mapping on the audit feed
- [ ] Repeated on every UI node
If you only take one thing from this: ship both feeds. The audit log knows where an action came from and the activity log knows what it actually did, and there is no single place on the appliance where those two facts meet.
Top comments (0)