Skip to content
Stribog

Security

All writing

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.

Stribog13 min read

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:

bash
# 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"
Two commands that settle the question. On a three-member control plane, run them three times — each API server writes its own file.

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.

First match wins and evaluation stops. The rules below whichever rule caught the event never run, which is why a broad early rule reads as a volume problem and behaves as a coverage hole.

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.

yaml
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: Metadata
A complete audit.k8s.io/v1 policy. The catch-all still captures surviving read traffic at Metadata; if that is more than you want to carry, add None rules for the get/list/watch paths you have decided not to keep — and add them above the catch-all, not below it.

Backends, 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.

yaml
# /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
  # ...
Excerpt of a static-pod manifest, not a complete one. The maxsize/maxbackup/maxage values here are a deliberate departure from the defaults: a smaller node-local window, sized to survive a SIEM outage rather than to be the archive.

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":

xml
<!-- 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>
One localfile block per API server. The labels are what let you tell three control-plane members apart once their events are interleaved in the indexer.

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:

bash
#!/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.json
Step 4 is the load-bearing one, and it does not run where steps 1-3 do: wazuh-logtest is a Wazuh server tool, so copy the sample line to the manager (or paste it into Tools > Ruleset test on the dashboard). Annotation keys contain dots and slashes, and how a given Wazuh version renders them into a matchable field name is something to observe, not assume.
Two places lose evidence silently — rotation on the node and buffer overflow on the webhook — and both sit upstream of every detection you will ever write.

Detections: 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.

xml
<!-- /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>
One parent, five children. The <field> element matches content the decoder extracted and accepts regex, sregex or pcre2. Rule 100603 is the one to read twice: frequency and timeframe only count when the parent is named with if_matched_sid, which matches on a rule triggered within a period. if_sid there would chain, not count — and the parent has to be 100602, because a level-0 rule raises no alert to count.

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:

json
{
  "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
      }
    ]
  }
}
Apply it with PUT _plugins/_ism/policies/wazuh-audit-retention-180d against the indexer, or through Index Management in the dashboard; the ism_template attaches it to indices created after that. The ISM policy encodes the 180 days. It cannot encode where the disk is — that is a placement decision made when you choose the hardware, and it is the half of the requirement no configuration file can satisfy.

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

  1. The control-plane disk fills. A policy broader than you modelled, maxsize set 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.
  2. 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.
  3. 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.
  4. API server memory grows. Auditing stores per-request context, so a policy change is a capacity change.
  5. Decoder output shifts under an upgrade. Rules are only as stable as the field names they match. Re-run wazuh-logtest after every upgrade and treat rule files as versioned artefacts with tests.
  6. 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.

  1. 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.
  2. 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.
  3. 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.

open source siemkubernetes audit log siemkubernetes audit policy configuration productionwazuh self hosted siem kuberneteskubernetes api server audit backendaudit log retention jurisdiction requirement

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.