Building Audit Trails for Zoho Mail: Real-Time Admin Monitoring with Wazuh

Building Audit Trails for Zoho Mail: Real-Time Admin Monitoring with Wazuh

index

A few months ago, I noticed Zoho had added a SIEM integration to their Mail admin portal. I started digging into it and found that it exposes 43 categories of admin audit logs, covering everything from user lifecycle events to mailbox delegation and domain changes. Better yet, those categories map easily to ISO 27001 controls I was already tracking.

The problem was access. These logs don’t show up in the admin UI, at least nowhere that was available at the time of writing. And pulling them via API would mean building a dedicated collector from scratch.

Since I already had Wazuh handling log ingestion and correlation across my infrastructure, the obvious move was to pipe these events straight into my existing SIEM pipeline — and build detection rules on top.

All configuration files, detection rules, and compliance mapping documentation are available in the GitHub repository.

PrerequisitesSection titled Prerequisites

  • Zoho Mail with admin access and webhook capabilities (SIEM logs are unavailable on free accounts)
  • Wazuh 4.x+ (tested on 4.14.0)
  • A dedicated Linux VM for Logstash (RockyLinux 9+ or Ubuntu 22.04+), placed in a DMZ
  • Wazuh agent installed on the receiver VM
  • SSL/TLS certificate for the webhook endpoint (Let’s Encrypt or commercial — self-signed won’t work)
  • Network connectivity:
    • Inbound HTTPS (port 443) from the internet to the Logstash VM
    • Outbound Wazuh agent connectivity from Logstash to Wazuh
  • Admin access to the Zoho Mail Admin Console and firewall management
  • 30 to 60 minutes to complete the integration

ArchitectureSection titled Architecture

Following the principle of defense in depth, Logstash sits on a separate, internet-facing VM rather than on the Wazuh Manager itself. If this receiver is ever compromised, the attacker still can’t reach the SIEM or tamper with historical logs already in Wazuh’s immutable archives.

Data FlowSection titled Data%20Flow

  1. Zoho Mail Admin Console pushes audit events via HTTPS webhook
  2. Logstash validates the secret token, parses, and enriches events
  3. Wazuh agent forwards the logs to the Wazuh manager
  4. Wazuh applies decoders and rules to generate alerts
  5. Wazuh Indexer stores data for search and compliance reporting
  6. Wazuh Dashboard provides visualization and investigation

Step 1: Firewall ConfigurationSection titled Step%201%3A%20Firewall%20Configuration

Zoho uses a push-based event system, meaning it requires an endpoint where to deliver the generated events. The first step is allowing web traffic to reach the Logstash VM.

If you’re using MikroTik RouterOS, you can use the following snippet to forward traffic from port 443 to Logstash’s port 5000:

/ip firewall nat
add action=dst-nat \
chain=dstnat \
dst-port=443 \
protocol=tcp \
to-addresses=10.x.x.x to-ports=5000

Step 2: Install and Configure LogstashSection titled Step%202%3A%20Install%20and%20Configure%20Logstash

Install Logstash:

Terminal window
# Ubuntu/Debian
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | \
sudo gpg --dearmor -o /usr/share/keyrings/elasticsearch-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/elasticsearch-keyring.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main" | \
sudo tee /etc/apt/sources.list.d/elastic-8.x.list
sudo apt update && sudo apt install logstash
sudo systemctl enable --now logstash

Set up certificates:

Terminal window
sudo mkdir -p /etc/logstash/certs
sudo cp /path/to/zoho.yourdomain.com.crt /etc/logstash/certs/
sudo cp /path/to/zoho.yourdomain.com.key /etc/logstash/certs/
sudo chown logstash:logstash /etc/logstash/certs/*
sudo chmod 600 /etc/logstash/certs/*.key

Input

The input settings configure Logstash to listen on the specified port. Enabling SSL ensures the event data is encrypted during transmission, protecting it against interception.

/etc/logstash/conf.d/01-zoho-mail-input.conf
input {
http {
port => 5000
ssl_enabled => true
ssl_certificate => "/etc/logstash/certs/zoho.yourdomain.com.crt"
ssl_key => "/etc/logstash/certs/zoho.yourdomain.com.key"
codec => json
add_field => { "[@metadata][input_type]" => "zoho_webhook" }
ecs_compatibility => disabled
}
}

Note: In my testing, Zoho’s webhook only delivered events to ports 80 and 443 — attempts to use non-standard ports failed silently. I couldn’t find any documentation confirming this limitation, but the behavior was consistent enough that I ended up forwarding port 443 on the firewall to Logstash’s listener on port 5000. If you’re planning to run Logstash on a high port, expect to need either a NAT rule or a reverse proxy in front of it.

Filters

The filter validates the secret token, adds the "source": "zoho-mail" field for Wazuh decoder matching, normalizes timestamps from Unix milliseconds, and parses nested JSON strings (data and previousData fields) into structured objects.

/etc/logstash/conf.d/02-zoho-mail-filter.conf
filter {
if [@metadata][input_type] == "zoho_webhook" {
# ── Webhook authentication ──────────────────────────────────
if [headers][x_zoho_webhook_token] != "YOUR_SECRET_TOKEN_HERE" {
drop { }
}
# ── Source identifier (Wazuh decoder prematch) ──────────────
mutate {
add_field => {
"source" => "zoho-mail"
"[@metadata][beat]" => "zoho-mail"
}
}
# ── Timestamp normalization ─────────────────────────────────
if [requestTime] {
date {
match => ["requestTime", "UNIX_MS"]
target => "@timestamp"
timezone => "UTC"
}
}
# ── Parse nested JSON strings ───────────────────────────────
# Zoho sends data/previousData as JSON strings within the
# payload. Wazuh's JSON_Decoder flattens these into dotted
# fields (dataDetails.domain, previousDataDetails.mailid, etc.)
# which the rules reference in descriptions.
if [data] {
json {
source => "data"
target => "dataDetails"
skip_on_invalid_json => true
}
}
if [previousData] {
json {
source => "previousData"
target => "previousDataDetails"
skip_on_invalid_json => true
}
}
# ── GeoIP enrichment ────────────────────────────────────────
if [clientIp] {
geoip {
source => "clientIp"
target => "geoip"
}
}
# ── Cleanup ─────────────────────────────────────────────────
mutate {
remove_field => ["headers", "host", "@version", "[event][original]", "data", "previousData"]
}
}
}

Replace x_zoho_webhook_token with your preferred header name and set your-secret-token-here to a strong, unique secret.

Output

Once Logstash finishes processing the incoming data and enriching it with tags and GeoIP information, the logs are written to disk.

/etc/logstash/conf.d/03-zoho-mail-output.conf
output {
if [@metadata][input_type] == "zoho_webhook" {
file {
path => "/var/log/logstash/zoho-mail.log"
codec => json_lines
}
}
}

Create the log directory and test the configuration:

Terminal window
# these should already exist, in case they don't create them
sudo mkdir -p /var/log/logstash
sudo chown logstash:logstash /var/log/logstash
# Validate configuration
sudo /usr/share/logstash/bin/logstash \
--config.test_and_exit -f /etc/logstash/conf.d/
sudo systemctl restart logstash
# Confirm it's listening
sudo ss -tlnp | grep 5000

Step 3: Configure Zoho Mail WebhooksSection titled Step%203%3A%20Configure%20Zoho%20Mail%20Webhooks

With the receiving infrastructure in place we now need to configure Zoho Mail Admin to send the event logs.

Zoho Mail Admin webhook config example
  1. Log into the Zoho Mail Admin Console
  2. Navigate to Other App Settings > SIEM > Security Information and Event Management (this feature is in beta at the time of writing — exact path may vary)
  3. Click Webhooks and select the event categories you want (I chose all 43)
  4. Name the webhook and select a data format (JSONArray or NDJSON)
  5. Enter your webhook URL: https://your-logstash-domain.com
  6. Configure the secret token and header name:
    • Customize the header name (e.g., X-Zoho-Webhook-Secret-Name-Token), this is the header name that Logstash will look for to validate that the data comes from Zoho
    • Set a strong random token (avoid characters that break HTTP headers)
  7. Save — Zoho will send a validation token to your endpoint

Important: Check the Logstash logs for the validation token, similar to this: {"otp":"12345678"}. Zoho won’t activate the webhook until you input this code back into the Admin Console.

If the connection fails, verify the the DNS resolves to your Logstash VM, the certificate is valid and from a trusted CA, Logstash is listening on the correct port, and the secret token matches exactly.

Step 4: Configure Wazuh to Receive LogsSection titled Step%204%3A%20Configure%20Wazuh%20to%20Receive%20Logs

Install the agent on the Logstash VM and add to its configuration:

/var/ossec/etc/shared/agent.conf
<ossec_config>
<localfile>
<log_format>json</log_format>
<location>/var/log/logstash/zoho-mail.log</location>
</localfile>
</ossec_config>

Step 5: Verify the PipelineSection titled Step%205%3A%20Verify%20the%20Pipeline

Check Logstash is receiving webhooks:

Terminal window
sudo tail -f /var/log/logstash/logstash-plain.log
sudo tail -f /var/log/logstash/zoho-mail.log

Trigger a test event by creating a new policy in the Zoho Mail Admin Console.

Verify Wazuh ingestion:

Terminal window
sudo tail -f /var/ossec/logs/ossec.log | grep zoho
sudo tail -f /var/ossec/logs/archives/archives.json | grep zoho-mail

Check the Wazuh Dashboard: Navigate to Explore > Discover, select the wazuh-archives-* index, and search for data.source:"zoho-mail".

If nothing appears: check for token validation failures in Logstash logs, verify agent connectivity, and review ossec.log for errors.

Step 6: Decoders and RulesSection titled Step%206%3A%20Decoders%20and%20Rules

Sample Webhook PayloadSection titled Sample%20Webhook%20Payload

Below is what a typical Zoho audit event looks like after Logstash processes it. This is the structure your decoders and rules will work with — keep it handy when writing or debugging rules, since every field name you reference in <field> tags must match exactly.

{
"source": "zoho-mail",
"category": "ORGMAILPOLICY",
"subCategory": "ORGMAILPOLICY_GENERAL",
"operationType": "ADD",
"operation": "ORG_MAILPOLICY_ADD",
"mainCategory": "Email Policy",
"performedBy": "[email protected]",
"performedTime": "2025-07-15T09:23:41.000Z",
"clientIp": "203.0.113.42",
"dataDetails": {
"policyName": "Restrict external forwarding",
"associatedGroups": [],
"associatedUsers": []
}
}

Key fields to understand: category and subCategory identify the event type, operationType tells you what happened (ADD, UPDATE, DELETE), dataDetails contains the specifics of the change, and previousDataDetails holds the prior state for UPDATE operations — useful for detecting what exactly changed.

DecodersSection titled Decoders

Navigate to Server Management > Decoders > Add new decoders file and create 0010-zoho-mail.xml:

0010-zoho-mail.xml
<!-- Parent decoder — identifies Zoho Mail logs -->
<decoder name="zoho_mail_audit">
<prematch>"source":"zoho-mail"</prematch>
</decoder>
<!-- JSON decoder — extracts all fields -->
<decoder name="zoho_mail_audit_json">
<parent>zoho_mail_audit</parent>
<plugin_decoder>JSON_Decoder</plugin_decoder>
</decoder>
<!-- Timestamp decoder -->
<decoder name="zoho_mail_audit_timestamp">
<parent>zoho_mail_audit</parent>
<regex type="pcre2">"eventTime":"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?)"</regex>
<order>event_timestamp</order>
</decoder>

The parent decoder matches on the "source":"zoho-mail" field added by Logstash. The JSON decoder extracts the full payload automatically.

Test with:

Terminal window
sudo /var/ossec/bin/wazuh-logtest
# Paste a sample Zoho event and verify fields are extracted correctly

Detection RulesSection titled Detection%20Rules

Create zoho_mail_rules.xml under Server Management > Rules > Add new rules file. The ruleset uses a base rule (ID 120050) that catches all Zoho Mail events, with child rules providing specific detections based on category, subCategory, and operationType fields from the webhook payload.

Category CoverageSection titled Category%20Coverage

Zoho Mail exposes 43 audit event categories, at the time of writing. The detection rules cover the categories I identified as most security-relevant for my own usecase. Everything else is handled by a catch-all rule (120099), so no event goes unlogged — you just need to decide which categories deserve dedicated rules in your environment.

Category coverage by rule ID This table shows which categories have specific detection rules and which are handled by the catch-all:

CategoryCoverageRule IDsNotes
MAILBOX (aliases, personal info, service, signatures)Dedicated rules120102–120107Subcategories split across multiple rules
ROLES_AND_PREVILEGESDedicated rules120115–120117ADD triggers email alert (level 12)
MAILGROUP (members, settings, roles)Dedicated rules120121, 120122120121 for role changes, 120122 general
SHAREDRESOURCES (mailbox, calendars, contacts)Dedicated rules120140–120143Shared mailbox gets its own rules, others use generic
ORGANIZATIONDedicated rule120170General org settings changes
DOMAINSDedicated rules120181–120183Add/delete are critical (level 12–13)
ORGMAILPOLICYDedicated rules120191–120193DELETE tagged as security_degradation
ORGMAILRESTRICTIONDedicated rules120201–120203DELETE tagged as security_degradation
PHISHING_AND_MALWAREDedicated rules120210–120211Removal triggers security_degradation
BLOCKED_LISTDedicated rules120220–120222DELETE tagged as security_degradation
All other categoriesCatch-all120099Review catch-all hits to identify patterns worth promoting

Tip: In the first month after deployment, review your catch-all (120099) hits weekly. Look at which categories appear most frequently and whether any represent security-relevant changes in your environment that warrant dedicated rules.

Example rules from the complete set:

<group name="zoho_mail,">
<!-- Base rule — all Zoho Mail events -->
<rule id="120050" level="3">
<field name="source">zoho-mail</field>
<description>Zoho Mail: Audit event - $(mainCategory) - $(operation) by $(performedBy)</description>
<group>zoho_mail,audit,</group>
<options>no_full_log</options>
</rule>
<!-- Admin role assignment — privilege escalation (level 12, email alert) -->
<rule id="120115" level="12">
<if_sid>120050</if_sid>
<field name="category">ROLES_AND_PREVILEGES</field>
<field name="operationType">ADD</field>
<description>Zoho Mail: Admin role '$(dataDetails.addedRole)' assigned to $(dataDetails.USERLIST) by $(performedBy) - ISO 27001:2022 A.8.2</description>
<group>zoho_mail,user_lifecycle,privilege_escalation,audit,pci_dss_10.2.5,nist_800_53_AC.6,gdpr_IV_35.7.d,</group>
<mitre><id>T1098.003</id></mitre>
<options>alert_by_email</options>
</rule>
<!-- Shared mailbox access granted — data access monitoring -->
<rule id="120140" level="10">
<if_sid>120050</if_sid>
<field name="category">SHAREDRESOURCES</field>
<field name="subCategory">SHARE_MAILBOX</field>
<field name="operationType">ADD</field>
<description>Zoho Mail: Shared mailbox access GRANTED to $(dataDetails.userEmailId) by $(performedBy) - ISO 27001:2022 A.8.3</description>
<group>zoho_mail,data_access,delegation_grant,audit,pci_dss_10.2.5,nist_800_53_AC.3,gdpr_IV_35.7.d,</group>
<mitre><id>T1098</id></mitre>
<options>no_full_log</options>
</rule>
<!-- Domain added — critical infrastructure change -->
<rule id="120181" level="12">
<if_sid>120050</if_sid>
<field name="category">DOMAINS</field>
<field name="operation">ORG_DOMAIN_ADD</field>
<description>Zoho Mail: Domain $(dataDetails.domain) ADDED to organization by $(performedBy) from $(clientIp) - ISO 27001:2022 A.8.9</description>
<group>zoho_mail,configuration,domain_management,critical,audit,pci_dss_10.2.5,nist_800_53_CM.3,gdpr_IV_35.7.d,</group>
<mitre><id>T1584.001</id></mitre>
<options>alert_by_email</options>
</rule>
<!-- Catch-all for unclassified events — ensures no blind spots -->
<!--
This rule fires for any Zoho Mail event that doesn't match a more specific rule above.
It sits at the bottom intentionally: Wazuh evaluates child rules of 120050 and matches
the most specific one first. If no specific rule matches, 120099 catches it at level 3.
Review these hits regularly to identify categories that need dedicated rules.
-->
<rule id="120099" level="3">
<if_sid>120050</if_sid>
<description>Zoho Mail: Unclassified audit event - $(category) / $(subCategory) $(operationType) by $(performedBy)</description>
<group>zoho_mail,audit,unclassified,</group>
<options>no_full_log</options>
</rule>
<!-- Correlation: Multiple security controls removed within 5 minutes -->
<rule id="120240" level="13" frequency="3" timeframe="300">
<if_matched_group>security_degradation</if_matched_group>
<description>Zoho Mail: ALERT - Multiple security controls removed within 5 minutes by $(performedBy) - Possible security tampering</description>
<group>zoho_mail,correlation,security_degradation,critical,audit,</group>
<mitre><id>T1562</id></mitre>
<options>alert_by_email</options>
</rule>
<!-- Correlation: Multiple domain operations — possible domain takeover -->
<rule id="120245" level="14" frequency="2" timeframe="600">
<if_matched_group>domain_management</if_matched_group>
<description>Zoho Mail: CRITICAL - Multiple domain management operations within 10 minutes - Possible domain takeover</description>
<group>zoho_mail,correlation,domain_takeover,critical,audit,</group>
<mitre><id>T1584.001</id></mitre>
<options>alert_by_email</options>
</rule>
</group>

The ruleset covers the most security-relevant Zoho Mail audit categories with MITRE ATT&CK mappings and compliance tags for NIST 800-53 and GDPR. The catch-all rule (120099) ensures that any category not covered by a dedicated rule still generates a level 3 alert, so nothing slips through silently while you tune the ruleset to your environment.

Field names like category, subCategory, operationType, and dataDetails.* come directly from Zoho’s webhook payload structure (refer to the sample payload above). The Logstash filter parses nested JSON into dataDetails and previousDataDetails objects.

Compliance MappingSection titled Compliance%20Mapping

Rule RangeISO 27001 ControlControl NameRequirement
120100–120139A.5.18, A.8.2Identity management, access rights, privileged accessAudit trail of alias management, personal info changes, role and privilege assignments, group membership
120140–120169A.8.3Information access restrictionMonitor shared mailbox delegation grants and revocations, shared resource access
120170–120222A.8.7, A.8.9, A.8.26Configuration management, application security & malware protectionConfiguration change control for organization settings, domains, email policies, restrictions, and anti-phishing/blocked list management
120240–120245-Anomaly & correlation detectionBehavioral patterns indicating security tampering, privilege abuse, delegation abuse, or domain takeover

Every administrative action is logged with who, what, when, and from where — providing the audit trails, access accountability, change management records, and data protection evidence that auditors expect.

Testing & ValidationSection titled Testing%20%26%20Validation

Generate test events by performing admin actions in Zoho: create a test user, assign an admin role, set up a forwarding rule, export a mailbox, modify a retention policy.

Watch alerts in real-time:

Terminal window
sudo tail -f /var/ossec/logs/alerts/alerts.json | jq 'select(.rule.groups[] | contains("zoho"))'

In the Wazuh Dashboard, navigate to Threat Hunting > Events and filter by rule.groups: zoho_mail. Verify each test event triggered the correct rule at the appropriate severity, then fine-tune thresholds and correlation timeframes to match your risk appetite.

TroubleshootingSection titled Troubleshooting

Zoho’s OTP validation times out: After saving the webhook in the Zoho Admin Console, Zoho sends a one-time validation code to your endpoint. You have a limited window (typically a few minutes) to paste it back. If you miss it, refresh the page, go to Webhook and click Validate. Before retrying, confirm Logstash is actually receiving requests by watching the Logstash log:

Terminal window
sudo tail -f /var/log/logstash/logstash-plain.log

Certificate chain issues: Zoho’s webhook client validates the full certificate chain. If you’re using Let’s Encrypt, make sure you’re providing the full chain file (fullchain.pem), not just the certificate.

Events appear in Logstash logs but not in Wazuh: Check these in order: the Wazuh agent is running on the Logstash VM (systemctl status wazuh-agent), the localfile configuration in agent.conf points to the correct log path, and the agent has restarted since you added the configuration. Then check ossec.log for file monitoring errors.

Fields don’t match in rules: Zoho’s payload field names are case-sensitive and occasionally inconsistent (note ROLES_AND_PREVILEGES — that’s Zoho’s spelling, not a typo in the rules). If a rule isn’t matching, paste a raw event into wazuh-logtest and compare the extracted field names against what your rule expects. The most common issue is referencing dataDetails.fieldName when the field is actually nested differently.

Alerts fire in wazuh-logtest but not in production: Usually a rule ordering issue. If a general rule (like 120122 for MAILGROUP) matches before your specific rule, the specific rule never fires. Check rule order in the file and confirm with wazuh-logtest using the verbose flag (-v).

What This Integration Doesn’t CoverSection titled What%20This%20Integration%20Doesn%u2019t%20Cover

This integration focuses on administrative audit logs pushed via Zoho’s SIEM webhook. There are several adjacent areas it doesn’t address, each of which could be a future extension:

  • Historical log backfill — Zoho’s webhook only sends events going forward. If you need historical data, you’d need to build a separate collector against Zoho’s Admin Audit API.
  • Zoho Workplace logs beyond Mail — Zoho Docs, Zoho Connect, and other Workplace apps have their own audit events that aren’t included in the Mail SIEM webhook.
  • End-user mailbox activity — These are admin audit logs, not user activity logs. Individual user actions (reading emails, creating filters) aren’t captured here.
  • Multi-organization setups — If you manage multiple Zoho organizations, each one needs its own webhook. The rules work as-is, but you’d want to add orgId-based filtering to distinguish between orgs in your alerts.

Next StepsSection titled Next%20Steps

With the pipeline operational, consider: scheduled compliance reports, ITSM integration for high-severity alerts, cross-source correlation with AD/VPN/EDR logs, and quarterly rule tuning as Zoho evolves its webhook format.

ConclusionSection titled Conclusion

This integration gives you real-time visibility into Zoho Mail admin actions — user lifecycle changes, mailbox delegation, domain modifications, and policy updates — with events mapped to ISO 27001 controls and flowing through a hardened, isolated pipeline into Wazuh.

The detection rules included here cover the security relevant categories, but they’re a starting point. Your environment will have its own risk profile: you might need tighter correlation windows, additional rules for categories currently on the catch-all, or custom severity levels based on which admin roles are most sensitive in your organization. The catch-all rule (120099) is there specifically so nothing slips through silently while you iterate.

If you’re running this in production, I’d recommend two things early on: enable alert_by_email on rules 120115 (admin role assignment) and 120181 (domain additions) which are some of the highest-impact events, and review your catch-all hits weekly for the first month to identify patterns worth promoting to dedicated rules.

The full ruleset, Logstash configuration, and compliance mapping documentation are on GitHub. If you’ve extended the rules or found edge cases in Zoho’s payload format, I’d welcome contributions — or just drop a note about what worked and what didn’t.