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
- Zoho Mail Admin Console pushes audit events via HTTPS webhook
- Logstash validates the secret token, parses, and enriches events
- Wazuh agent forwards the logs to the Wazuh manager
- Wazuh applies decoders and rules to generate alerts
- Wazuh Indexer stores data for search and compliance reporting
- 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=5000Step 2: Install and Configure LogstashSection titled Step%202%3A%20Install%20and%20Configure%20Logstash
Install Logstash:
# Ubuntu/Debianwget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | \ sudo gpg --dearmor -o /usr/share/keyrings/elasticsearch-keyring.gpgecho "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.listsudo apt update && sudo apt install logstashsudo systemctl enable --now logstash# RockyLinux/RHELsudo rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearchcat <<EOF | sudo tee /etc/yum.repos.d/elastic-8.x.repo[elastic-8.x]name=Elastic repository for 8.x packagesbaseurl=https://artifacts.elastic.co/packages/8.x/yumgpgcheck=1gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearchenabled=1autorefresh=1type=rpm-mdEOFsudo dnf install logstashsudo systemctl enable --now logstashSet up certificates:
sudo mkdir -p /etc/logstash/certssudo 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/*.keyInput
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.
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.
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.
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:
# these should already exist, in case they don't create themsudo mkdir -p /var/log/logstashsudo chown logstash:logstash /var/log/logstash
# Validate configurationsudo /usr/share/logstash/bin/logstash \ --config.test_and_exit -f /etc/logstash/conf.d/
sudo systemctl restart logstash
# Confirm it's listeningsudo ss -tlnp | grep 5000Step 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.

- Log into the Zoho Mail Admin Console
- 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)
- Click Webhooks and select the event categories you want (I chose all 43)
- Name the webhook and select a data format (JSONArray or NDJSON)
- Enter your webhook URL:
https://your-logstash-domain.com - 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)
- Customize the header name (e.g.,
- 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:
<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:
sudo tail -f /var/log/logstash/logstash-plain.logsudo tail -f /var/log/logstash/zoho-mail.logTrigger a test event by creating a new policy in the Zoho Mail Admin Console.
Verify Wazuh ingestion:
sudo tail -f /var/ossec/logs/ossec.log | grep zohosudo tail -f /var/ossec/logs/archives/archives.json | grep zoho-mailCheck 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", "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:
<!-- 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:
sudo /var/ossec/bin/wazuh-logtest# Paste a sample Zoho event and verify fields are extracted correctlyDetection 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:
| Category | Coverage | Rule IDs | Notes |
|---|---|---|---|
| MAILBOX (aliases, personal info, service, signatures) | Dedicated rules | 120102–120107 | Subcategories split across multiple rules |
| ROLES_AND_PREVILEGES | Dedicated rules | 120115–120117 | ADD triggers email alert (level 12) |
| MAILGROUP (members, settings, roles) | Dedicated rules | 120121, 120122 | 120121 for role changes, 120122 general |
| SHAREDRESOURCES (mailbox, calendars, contacts) | Dedicated rules | 120140–120143 | Shared mailbox gets its own rules, others use generic |
| ORGANIZATION | Dedicated rule | 120170 | General org settings changes |
| DOMAINS | Dedicated rules | 120181–120183 | Add/delete are critical (level 12–13) |
| ORGMAILPOLICY | Dedicated rules | 120191–120193 | DELETE tagged as security_degradation |
| ORGMAILRESTRICTION | Dedicated rules | 120201–120203 | DELETE tagged as security_degradation |
| PHISHING_AND_MALWARE | Dedicated rules | 120210–120211 | Removal triggers security_degradation |
| BLOCKED_LIST | Dedicated rules | 120220–120222 | DELETE tagged as security_degradation |
| All other categories | Catch-all | 120099 | Review 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 Range | ISO 27001 Control | Control Name | Requirement |
|---|---|---|---|
| 120100–120139 | A.5.18, A.8.2 | Identity management, access rights, privileged access | Audit trail of alias management, personal info changes, role and privilege assignments, group membership |
| 120140–120169 | A.8.3 | Information access restriction | Monitor shared mailbox delegation grants and revocations, shared resource access |
| 120170–120222 | A.8.7, A.8.9, A.8.26 | Configuration management, application security & malware protection | Configuration change control for organization settings, domains, email policies, restrictions, and anti-phishing/blocked list management |
| 120240–120245 | - | Anomaly & correlation detection | Behavioral 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:
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:
sudo tail -f /var/log/logstash/logstash-plain.logCertificate 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.