
Security
Kubernetes Audit Logs Into a SIEM You Operate: Wazuh
An open source SIEM starts at the kube-apiserver audit log: write a policy that survives production volume, ship it to Wazuh, retain it in jurisdiction.
Runtime detection gets the budget — all worth doing, and covered in Falco and Tetragon. Meanwhile the highest-value security log a cluster produces sits unconfigured on the control plane, answering the question every incident reduces to: who asked the API for what, and did it say yes.
The Highest-Value Log in the Cluster Is the One Nobody Turned On
The kube-apiserver audit log is a chronological record of requests reaching the API server: who called, what they asked for, and what the authorizer decided. Every controller action, every kubectl invocation, every service-account token used by a compromised pod arrives at the same door. Nothing else in the cluster has that vantage point.
It is also silent by default. Upstream is unambiguous: you pass a policy with --audit-policy-file, and "if the flag is omitted, no events are logged." Not reduced events. None. The second trap follows immediately — "the rules field must be provided in the audit policy file. A policy with no (0) rules is treated as illegal," so a file that exists but is empty fails the API server rather than quietly logging nothing.
That is a claim about the flag, not your installer. Check the running manifest on every control-plane node first:
# Configured at all, and is the file growing?
sudo grep -E 'audit-(policy-file|log-path|log-mode|log-max)' \
/etc/kubernetes/manifests/kube-apiserver.yaml || echo "no audit flags"
sudo ls -l /var/log/kubernetes/audit/ || echo "no audit directory"Everything below was checked against Kubernetes 1.36 — 1.36.2 is the current patch — and Wazuh 4.14.7. Both will move; the decisions will not.
Writing an Audit Policy You Can Afford to Keep
The policy is an ordered list, and order is the whole design: "when an event is processed, it's compared against the list of rules in order. The first matching rule sets the audit level of the event." A permissive rule placed early does not merely add volume — it disables every rule beneath it for the traffic it catches, and nothing in kubectl will tell you.
Four levels exist. None drops the event. Metadata logs "requesting user, timestamp, resource, verb, etc. but not request or response body". Request adds the request body. RequestResponse logs "request metadata, request body and response body" — exactly why it must never touch Secrets: set it there and the API server writes the secret material into a plaintext file on the node. Kubernetes' own example policy is explicit, logging "configmap and secret changes in all other namespaces at the Metadata level."
Volume work is done by the None rules at the top and by omitting the RequestReceived stage, which otherwise doubles every request into a second event carrying no outcome. Order the file like a firewall: cheap drops first, precision next, catch-all last.
apiVersion: audit.k8s.io/v1
kind: Policy
# One event per request, at the stage where the outcome is known.
omitStages:
- RequestReceived
rules:
# Cheap drops first: first match wins, so these precede everything.
- level: None
users: ["system:kube-proxy"]
verbs: ["watch"]
resources:
- group: ""
resources: ["endpoints", "services", "services/status"]
# Probes, discovery and scrapes. High rate, no security value.
- level: None
userGroups: ["system:authenticated"]
nonResourceURLs:
- "/healthz*"
- "/livez*"
- "/readyz*"
- "/version"
- "/metrics"
- "/openapi*"
# Metadata, never RequestResponse: the body IS the secret.
- level: Metadata
resources:
- group: ""
resources: ["secrets", "configmaps"]
- group: "authentication.k8s.io"
resources: ["tokenreviews"]
# Low-volume, and the granted rules are the evidence.
- level: RequestResponse
resources:
- group: "rbac.authorization.k8s.io"
resources:
["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
# The request body carries the command that was run.
- level: Request
resources:
- group: ""
resources: ["pods/exec", "pods/attach", "pods/portforward"]
# Catch-all: everything the rules above did not name.
- level: MetadataBackends, and the Default That Sits in Your Request Path
Two backends ship. The log backend "writes audit events to a file in JSONlines format" — one JSON object per line, on the node, the property everything downstream depends on. The webhook backend posts events to a remote API.
The default that surprises people is --audit-log-mode, documented as Default: "blocking", meaning "sending events should block server responses." Every audit write sits in the request path until you decide otherwise. Stricter still is blocking-strict, where "when there is a failure during audit logging at the RequestReceived stage, the whole request to the kube-apiserver fails" — right when an unloggable request should be refused, wrong everywhere else.
The obvious escape is --audit-log-mode=batch, and upstream declines to recommend it: "Batching is not recommended for the log backend." Batching is on by default for the webhook backend and off for the log backend. If synchronous writes are too expensive, the honest fix is a smaller policy, not an asynchronous one.
Rotation defaults matter more than they look. --audit-log-maxsize defaults to 100 MB per file, --audit-log-maxbackup to 100 files, --audit-log-maxage to 366 days. The first two multiply to a node-local ceiling of roughly 10 GB before rotation starts discarding — our arithmetic on two documented defaults, not a figure Kubernetes publishes, and a ceiling rather than a retention policy. Setting maxsize to 0 disables rotation, which the docs flag as not recommended and which fills a control-plane disk.
Leave --audit-log-format alone. It defaults to json; the alternative, legacy, is a one-line text format, and feeding that to a JSON decoder produces a pipeline that looks configured and matches nothing.
# /etc/kubernetes/manifests/kube-apiserver.yaml — excerpt
spec:
containers:
- name: kube-apiserver
command:
- kube-apiserver
# ...
- --audit-policy-file=/etc/kubernetes/audit/policy.yaml
- --audit-log-path=/var/log/kubernetes/audit/audit.log
- --audit-log-format=json
- --audit-log-mode=blocking
- --audit-log-maxsize=200
- --audit-log-maxbackup=30
- --audit-log-maxage=14
volumeMounts:
- name: audit-policy
mountPath: /etc/kubernetes/audit
readOnly: true
- name: audit-log
mountPath: /var/log/kubernetes/audit
volumes:
- name: audit-policy
hostPath:
path: /etc/kubernetes/audit
type: DirectoryOrCreate
- name: audit-log
hostPath:
path: /var/log/kubernetes/audit
type: DirectoryOrCreate
# ...Budget memory as well as disk: "the audit logging feature increases the memory consumption of the API server because some context required for auditing is stored for each request," and the amount depends on your configuration. A policy that logs bodies costs more than one logging metadata. Watch the API server after the rollout, not after the incident.
Shipping the Log to a SIEM You Operate
Wazuh has published this pipeline twice on its own blog. "Auditing Kubernetes with Wazuh" (23 February 2026, Wazuh 4.14.3) builds a webhook listener on the Wazuh server. "Detecting Kubernetes attacks with Wazuh" (30 April 2026, Wazuh 4.14.5) takes the log-backend route, deploying the agent as a DaemonSet with rules for Stratus Red Team techniques. Both demonstrate on Minikube on AlmaLinux 9. What follows is the production remainder — the volume, rotation, retention and control-plane-access questions a single-node walkthrough is not trying to answer.
The agent side is small, because the audit log already arrives in the format Wazuh's collector wants — its json log format is documented as being for "single-line JSON files":
<!-- Merge this localfile block into the existing <ossec_config> in
/var/ossec/etc/ossec.conf on each control-plane node.
Do not replace the file with it. -->
<ossec_config>
<localfile>
<location>/var/log/kubernetes/audit/audit.log</location>
<log_format>json</log_format>
<label key="cluster">prod-eu-1</label>
<label key="component">kube-apiserver</label>
</localfile>
</ossec_config>Wazuh's built-in JSON decoder handles the parse: it "extracts each field from the JSON log data for comparison against the rules, eliminating the need for a specific" source decoder, flattening nested keys into dot-separated names — its documented example decodes a Suricata event to alert.action, with no data. prefix. The mechanism is generic; the field names are yours to confirm.
Capture them from your own cluster before writing a rule. That is what wazuh-logtest is for, and it is the step people skip:
#!/usr/bin/env bash
set -euo pipefail
# Steps 1-3: each control-plane node. Step 4: the Wazuh manager.
AUDIT_LOG=/var/log/kubernetes/audit/audit.log
# 1. Emitting anything at all? An empty file is the symptom of a missing flag.
sudo grep -c . "$AUDIT_LOG" || echo "no audit events on this node"
# 2. Which authorization decisions were refused in the current file?
sudo jq -r 'select(.annotations["authorization.k8s.io/decision"] == "forbid")
| [.requestReceivedTimestamp, .user.username, .verb,
(.objectRef.resource // "-"), (.objectRef.subresource // "-")]
| @tsv' "$AUDIT_LOG" | tail -20
# 3. Keep one real event to test with.
sudo tail -1 "$AUDIT_LOG" > /tmp/audit-sample.json
# 4. Manager only: wazuh-logtest ships with the server, not the agent.
# Print the field names the decoder exposes for YOUR events; write
# rules against that, not against names copied from a blog.
/var/ossec/bin/wazuh-logtest < /tmp/audit-sample.jsonDetections: Refusals, Anonymous Callers, RBAC Writes
Start with what you are not getting for free. We listed the ruleset directory at tag v4.14.7: 168 rule files, and not one targets the Kubernetes API server audit log — the nearest neighbours are Linux auditd and MySQL audit rules, different sources entirely. The Kubernetes detections in both Wazuh posts are blog rules, not shipped ruleset rules. They are yours to carry, version alongside the cluster, and re-test after every upgrade.
It also frees you to write them properly. Wazuh's published Kubernetes rules chain from a parent matching the decoder field apiVersion, with children matching <regex type="pcre2"> against raw requestURI text. Matching decoder-extracted fields is cheaper — the decoder already parsed the line — and survives URL-encoding changes a raw-text regex does not. The chaining mechanism is if_sid, which "matches if the log has previously matched a rule in the specified ID," so one cheap parent gates the tree. Counting is a different option — if_matched_sid, which fires only if the named rule triggered inside a window, and the one frequency and timeframe require.
Four detections earn their place. Refusals — the authorization filter stamps authorization.k8s.io/decision as allow or forbid on every event, alongside authorization.k8s.io/reason, and annotations are included at Metadata level, so this works under the policy above. One forbid is noise; a burst inside a minute is reconnaissance. Anonymous callers — unauthenticated requests that are not rejected arrive as system:anonymous in group system:unauthenticated, detectable from the audit stream alone. RBAC writes — captured at RequestResponse, so the alert carries the granted rules, and the reason annotation on later allows names the binding as RBAC: allowed by <source>. Exec into a container — MITRE ATT&CK T1609 describes remote execution "by running a command such as kubectl exec"; that it surfaces as a create on the pods/exec subresource is our mapping, not something MITRE states.
<!-- /var/ossec/etc/rules/kubernetes_audit_rules.xml
Custom rules live in /var/ossec/etc/rules/; IDs 100000-120000 are
reserved for them.
BEFORE DEPLOYING: every <field name="..."> below is a placeholder.
Run wazuh-logtest on a real audit line from your own cluster and
replace each name with what the decoder actually prints. Annotation
keys contain dots and slashes; do not assume how they render. -->
<group name="kubernetes,audit,">
<!-- Cheap parent gating everything below. -->
<rule id="100600" level="0">
<field name="kind">^Event$</field>
<field name="apiVersion">^audit\.k8s\.io/v1$</field>
<description>Kubernetes API server audit event.</description>
</rule>
<rule id="100601" level="8">
<if_sid>100600</if_sid>
<field name="user.username">^system:anonymous$</field>
<description>Kubernetes: request from an unauthenticated caller.</description>
</rule>
<rule id="100602" level="4">
<if_sid>100600</if_sid>
<field name="annotations.authorization.k8s.io/decision">^forbid$</field>
<description>Kubernetes: the authorizer refused a request.</description>
</rule>
<!-- Counting needs if_matched_sid, not if_sid, and a parent above level 0. -->
<rule id="100603" level="10" frequency="12" timeframe="60">
<if_matched_sid>100602</if_matched_sid>
<description>Kubernetes: repeated refusals in one minute.</description>
</rule>
<rule id="100604" level="12">
<if_sid>100600</if_sid>
<field name="objectRef.resource">^clusterrolebindings$</field>
<field name="verb">^create$|^update$|^patch$</field>
<description>Kubernetes: cluster-wide role binding written.</description>
</rule>
<rule id="100605" level="10">
<if_sid>100600</if_sid>
<field name="objectRef.subresource">^exec$</field>
<field name="verb">^create$</field>
<description>Kubernetes: exec into a container (MITRE T1609).</description>
</rule>
</group>Rule 100604 pays for the pipeline on its own. A write to a ClusterRoleBinding is how most Kubernetes privilege escalation ends, and it is rare in a mature cluster. Pair it with secrets held in an external store and the two cover the path most intrusions take.
Retention Where the Jurisdiction Requires It
Node-local rotation is not retention, and the arithmetic is worth doing out loud. The kube-apiserver manifest published in Wazuh's own post sets --audit-log-maxage=10, --audit-log-maxbackup=5 and --audit-log-maxsize=100 — our arithmetic on their published values gives roughly 500 MB and ten days on the node. Entirely reasonable for a demonstration, and no 180-day obligation can be satisfied from it. Nothing in the cluster will tell you; the file simply rotates away.
If you operate in India, the number is written down. CERT-In's April 2022 direction under section 70B(6) requires that organisations "mandatorily enable logs of all their ICT systems and maintain them securely for a rolling period of 180 days and the same shall be maintained within the Indian jurisdiction." Two obligations in one sentence needing separate engineering: a duration and a location. The full CERT-In obligation is written up elsewhere.
The duration is an Index State Management policy on the indexer, where lifecycle policies drive deletion from age thresholds such as min_index_age:
{
"policy": {
"policy_id": "wazuh-audit-retention-180d",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [],
"transitions": [
{ "state_name": "delete", "conditions": { "min_index_age": "180d" } }
]
},
{
"name": "delete",
"actions": [{ "delete": {} }],
"transitions": []
}
],
"ism_template": [
{
"index_patterns": ["wazuh-alerts-*"],
"priority": 100
}
]
}
}Keep those two apart. An ISM policy is a delete schedule; jurisdiction is a property of the storage you provisioned, and collapsing them into "ISM gives us CERT-In compliance" survives an internal review and fails an external one. The same direction requires reporting cyber incidents listed in its Annexure I "within 6 hours of noticing such incidents" — and Annexure I enumerates incident types, not SIEM rule IDs. Whether a given alert is reportable is a determination your counsel makes, not one your rule severity makes.
Failure Modes, in the Order You Will Meet Them
- The control-plane disk fills. A policy broader than you modelled,
maxsizeset to 0, or a busy cluster meeting the default ceiling. Alert on free space on the audit filesystem before you alert on anything in the log. - Rotation outruns collection. The agent falls behind, the file rotates, the events are gone. Nothing reports this: the log looks healthy and the SIEM looks quiet. Monitor lag against rotation rate.
- Webhook batches drop. The buffer is documented plainly — "if the rate of incoming events overflows the buffer, events are dropped," default 10000. Silent loss at the moment a burst makes the events most interesting.
- API server memory grows. Auditing stores per-request context, so a policy change is a capacity change.
- Decoder output shifts under an upgrade. Rules are only as stable as the field names they match. Re-run
wazuh-logtestafter every upgrade and treat rule files as versioned artefacts with tests. - There is no file to read. Talos Linux gives no shell to tail a hostPath, and a managed control plane exposes audit only through the provider. Here the webhook backend stops being optional.
The last inverts the usual advice. Immutability and managed control planes are good decisions; they move the pipeline from a file read to a network push, and the failure mode moves with it. Decide which backend you are on before you write the policy.
Exit Ramp: The Log Is the Asset, the SIEM Is a Tenant
Build it this way and almost none of it is Wazuh-specific. Sort it into three buckets and the switching cost stops being a mystery.
- Travels intact. The audit policy, the JSONLines files, and the archive. This is the evidence, it is a documented Kubernetes API type, and every SIEM worth evaluating ingests single-line JSON. Nothing here is anybody's format.
- Travels with work. The detection logic. The intent behind rule 100604 — cluster-wide role binding written — is portable; the XML expressing it is not. Keep the intent in prose beside the rules so a rewrite is translation, not rediscovery.
- Does not travel. Wazuh rule XML, the ISM policy, dashboards keyed to indexer field names, agent configuration. Real work, correctly done, and vendor-shaped. Price it as such.
State the licensing precisely rather than as a slogan, because the halves differ: the Wazuh manager is licensed under GPL version 2, while the Wazuh indexer is an Apache-2.0 licensed fork of OpenSearch. Both open source, on different terms. "Wazuh is Apache-2.0" is wrong, and worth knowing before you build a compliance argument on it.
Against a hosted SIEM billed per ingested gigabyte, the shape of the decision changes as well as the price: the audit policy stops being a cost-control instrument and goes back to being a security decision. That is the argument for self-hosting observability, applied to the log you are least willing to sample.
The Long Game: An Evidence Trail That Outlives the Tooling
Detection tooling turns over faster than the questions it answers, which are asked in years and answered from whatever was written down at the time. The organisations that answer well are rarely the ones with the best rules — they are the ones that kept the raw record, in an open format, on storage they control, for the period they said they would.
Which reduces to something sustainable. Own the policy, because it decides what exists. Own the file, because it is the evidence. Treat the SIEM as the current tenant on both, and detections as software with tests and a version history — the discipline behind network policy you can prove is enforced and SOC 2 evidence you generate yourself.
A team that can produce, on request, the exact API calls a compromised service account made eighteen months ago owns its security history. A team that produces a dashboard screenshot owns a screenshot.
§FAQ/Common questions
Frequently asked
Is Kubernetes audit logging enabled by default?
No. The kube-apiserver logs audit events only when you pass a policy file: the upstream documentation states that you supply the policy with --audit-policy-file and that if the flag is omitted, no events are logged. That is a statement about the flag, not about any particular distribution — some installers ship a policy of their own, so check the running kube-apiserver manifest on every control-plane node rather than assuming either way. Note also that an empty policy is not a valid way to disable auditing: the rules field must be provided, and a policy with zero rules is treated as illegal.
Why should Kubernetes Secrets be logged at Metadata level and not RequestResponse?
Because RequestResponse logs request metadata, the request body and the response body — and for a Secret, the body is the secret material. Setting that level on the core secrets resource writes credentials in plaintext into the audit file on every control-plane node, and then into whatever you ship that file to, including your SIEM index and its backups. Kubernetes' own example policy makes the safe choice explicitly, logging configmap and secret changes at the Metadata level, which records the requesting user, timestamp, resource and verb without either body. Metadata is enough to detect anomalous Secret access; RequestResponse turns your audit trail into a second copy of the credential store.
Should I set --audit-log-mode to batch to reduce API server latency?
Probably not for the log backend. The default is blocking, meaning audit writes block server responses, and that default is deliberate — the Kubernetes documentation states that batching is not recommended for the log backend, and batching is enabled by default for the webhook backend and disabled for the log backend. Batch mode trades request-path latency for the possibility of losing buffered events, which defeats the reason you enabled auditing. If synchronous writes are genuinely too expensive, shrink the policy: add None rules for high-volume watch and health traffic, and omit the RequestReceived stage so each request produces one event rather than two. There is also blocking-strict, which fails the whole request when audit logging fails at the RequestReceived stage — correct where an unloggable request should be refused outright.
Does Wazuh ship built-in rules for Kubernetes audit logs?
Not as of version 4.14.7. We listed the ruleset directory at that tag: 168 rule files, none of which target the Kubernetes API server audit log — the nearest neighbours are Linux auditd and MySQL audit rules, which parse entirely different sources. The Kubernetes detections in Wazuh's own blog posts are written in those posts, not shipped in the ruleset, so they are yours to carry: version them alongside the cluster, keep them in /var/ossec/etc/rules/ with IDs in the reserved 100000-120000 range, and re-test after every Wazuh and Kubernetes upgrade. Confirm the field names your decoder produces with wazuh-logtest first; do not copy field paths from an article, including this one.
Does an indexer retention policy satisfy CERT-In's 180-day log requirement?
It satisfies half of it. CERT-In's April 2022 direction under section 70B(6) requires logs of all ICT systems to be maintained securely for a rolling period of 180 days and maintained within the Indian jurisdiction — a duration and a location, in one sentence. An Index State Management policy on the Wazuh indexer encodes the duration: transition to a delete state at a min_index_age of 180d, applied to the alert indices through an ism_template. It cannot encode where the disk is. Jurisdiction is decided when you choose the hardware and the facility, and no configuration file will make a cluster hosted elsewhere compliant. Treat them as two separate pieces of work with two separate pieces of evidence.
Further reading
- RBI and SEBI cloud rules: India's BFSI data boundary
- OpenFeature: ports your code, not your flag data
- Falco vs Tetragon: kernel-time Kubernetes threat detection
- CERT-In log retention: India's 6-hour rule and 180 days
- Self-hosted observability: OpenTelemetry, Prometheus, Loki
- SOC 2 on self-hosted Kubernetes: own the evidence
- Kubernetes secrets are still broken: ESO over Vault
- Default deny, actually: auditing Kubernetes network policy
- Talos Linux: the security case for an immutable OS
- Security and compliance engineering
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.