Detecting Kubernetes misconfigurations with KubeLinter and Wazuh

| by | Wazuh 4.14.4
Post icon

Kubernetes misconfigurations introduce security risks in containerized environments. Containers running in privileged mode, workloads without CPU or memory limits, and workloads that run as root are common mistakes that can lead to privilege escalation, node compromise, or denial-of-service conditions. These issues are often introduced during development and remain undetected until deployment, making early detection important.

KubeLinter is an open source static analysis tool that scans Kubernetes manifests and Helm charts for security and reliability issues. It detects kubernetes misconfigurations such as privileged containers, missing resource limits, and unsafe defaults, producing structured findings in JSON format.

By integrating KubeLinter with Wazuh, these misconfiguration findings can be ingested, correlated, and alerted on, just like any other security event. This gives security and platform teams centralized visibility into Kubernetes manifest misconfigurations and helps them detect insecure configurations before deployment.

Infrastructure

To demonstrate this integration, we use the following infrastructure:

  • A prebuilt, ready-to-use Wazuh OVA 4.14.4, which includes the Wazuh central components (Wazuh server, Wazuh indexer, and Wazuh dashboard). Follow this guide to download and set up the Wazuh virtual machine.
  • An Ubuntu 24.04 endpoint with the Wazuh agent 4.14.4 installed and enrolled in the Wazuh server. We install Minikube and KubeLinter on this endpoint.

Configuration

Ubuntu

Perform the following steps on the Ubuntu endpoint.

Install and configure Minikube

Set up a local Kubernetes environment to test the KubeLinter and Wazuh integration. In thie blog post, Minikube runs a single-node Kubernetes cluster locally using Docker as the container driver.

  1. Update the system packages:
# apt update && apt upgrade -y
  1. Install Docker to serve as the container driver for Minikube:
# apt install -y docker.io
# systemctl enable docker
# systemctl start docker
# usermod -aG docker $USER
# newgrp docker
  1. Install kubectl:
# curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
# chmod +x kubectl
# mv kubectl /usr/local/bin/
# kubectl version --client
  1. Download and install the Minikube binary:
# curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
# chmod +x minikube-linux-amd64
# mv minikube-linux-amd64 /usr/local/bin/minikube
  1. Start the Minikube cluster using the Docker driver:
# minikube start --driver=docker
  1. Verify the cluster is running:
# kubectl get nodes
NAME       STATUS   ROLES           AGE   VERSION
minikube   Ready    control-plane   9s    v1.35.1

Install KubeLinter

  1. Run the following command to download and install the KubeLinter binary:
# curl -L -o kube-linter \
https://github.com/stackrox/kube-linter/releases/latest/download/kube-linter-linux
# chmod +x kube-linter
# mv kube-linter /usr/local/bin/
  1. Verify the installation:
# kube-linter version
0.8.3
  1. Run the following command to verify the available default checks. KubLinter will display a comprehensive list of built-in checks, including their names, descriptions, and recommended remediation steps.
# kube-linter checks list

Create a misconfigured Kubernetes manifest

Create a Kubernetes manifest with common security misconfigurations to test the KubeLinter and Wazuh integration. This gives KubeLinter findings to detect and gives Wazuh events to ingest and alert on.

  1. Create a directory to store your Kubernetes manifest:
# mkdir -p /etc/kubernetes/manifests
# cd /etc/kubernetes/manifests
  1. Create dedicated namespaces for the test workload:
# kubectl create namespace app1
  1. Create a file named insecure-deployment.yaml in /etc/kubernetes/manifests with the following intentionally insecure content. This manifest intentionally violates several Kubernetes security best practices. It uses the latest image tag, exposes a secret in an environment variable, allows privileged execution, runs as root, uses a writable root filesystem, and defines no resource requests or limits:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: insecure-app
  namespace: app1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: insecure-app
  template:
    metadata:
      labels:
        app: insecure-app
    spec:
      containers:
        - name: insecure-app
          image: nginx:latest
          env:
            - name: DB_PASSWORD_SECRET
              value: "secretpassword"
          securityContext:
            runAsNonRoot: false
            readOnlyRootFilesystem: false
            privileged: true
          resources: {}
  1. Test KubeLinter manually against the manifest to confirm the checks:
# kube-linter lint insecure-deployment.yamlKubeLinter 0.8.3

/etc/kubernetes/manifests/insecure-deployment.yaml: (object: frontend/insecure-app apps/v1, Kind=Deployment) environment variable DB_PASSWORD_SECRET in container "insecure-app" found (check: env-var-secret, remediation: Do not use raw secrets in environment variables. Instead, either mount the secret as a file or use a secretKeyRef. Refer to https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets for details.)

/etc/kubernetes/manifests/insecure-deployment.yaml: (object: frontend/insecure-app apps/v1, Kind=Deployment) The container "insecure-app" is using an invalid container image, "nginx:latest". Please use images that are not blocked by the `BlockList` criteria : [".*:(latest)$" "^[^:]*$" "(.*/[^:]+)$"] (check: latest-tag, remediation: Use a container image with a specific tag other than latest.)

...

Error: found 8 lint errors

...

Preparing KubeLinter Output for Wazuh

Wazuh requires JSON Lines (JSONL) format for optimal ingestion, where each event is a single JSON object per line. KubeLinter outputs a single JSON array, so we use a wrapper script to flatten the data into individual objects for seamless processing by Wazuh.

The wrapper script writes each finding as a separate JSON line in /var/log/kubelinter.log. It first writes the scan output to a temporary file and replaces the log file only after the scan completes. This prevents Wazuh from reading partial results and keeps only the latest scan results in the log file.

  1. Create a KubeLinter wrapper script /usr/local/bin/kubelinter-scan.sh:
# touch /usr/local/bin/kubelinter-scan.sh
  1. Add the following content to the KubeLinter wrapper script, /usr/local/bin/kubelinter-scan.sh. Ensure to specify MANIFEST_DIR and LOG_FILE.
#!/usr/bin/env bash
# KubeLinter scan script for Wazuh integration
set -euo pipefail

# The script runs KubeLinter against this path. Update this path if your manifests are stored elsewhere.
MANIFEST_DIR="/etc/kubernetes/manifests"

# Wazuh monitors this log file and ingests each JSON line as a separate event.
LOG_FILE="/var/log/kubelinter.log"

TMP_FILE="$(mktemp)"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

RESULTS=$(kube-linter lint "$MANIFEST_DIR" --format json 2>/dev/null || true)

if [[ -n "$RESULTS" ]]; then
  echo "$RESULTS" | python3 -c "
import sys, json

data = json.load(sys.stdin)
reports = data.get('Reports', [])

for r in reports:
    flat = {
        'integration':    'kubelinter',
        'check':          r.get('Check', ''),
        'remediation':    r.get('Remediation', ''),
        'message':        r.get('Diagnostic', {}).get('Message', ''),
        'kind':           r.get('Object', {}).get('K8sObject', {}).get('GroupVersionKind', {}).get('Kind', ''),
        'name':           r.get('Object', {}).get('K8sObject', {}).get('Name', ''),
        'namespace':      r.get('Object', {}).get('K8sObject', {}).get('Namespace', ''),
        'file':           r.get('Object', {}).get('Metadata', {}).get('FilePath', ''),
        'scan_timestamp': sys.argv[1]
    }
    print(json.dumps(flat))
" "$TIMESTAMP" > "$TMP_FILE"

  mv "$TMP_FILE" "$LOG_FILE"
  chmod 0644 "$LOG_FILE"
else
  : > "$LOG_FILE"
fi
  1. Make the script executable:
# chmod +x /usr/local/bin/kubelinter-scan.sh
  1. Test the script manually and verify that the output is written to the log file:
# /usr/local/bin/kubelinter-scan.sh
# cat /var/log/kubelinter.log | head -5
...

{"integration": "kubelinter", "check": "env-var-secret", "remediation": "Do not use raw secrets in environment variables. Instead, either mount the secret as a file or use a secretKeyRef. Refer to https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets for details.", "message": "environment variable DB_PASSWORD_SECRET in container \"insecure-app\" found", "kind": "Deployment", "name": "insecure-app", "namespace": "frontend", "file": "/etc/kubernetes/manifests/insecure-deployment.yaml", "scan_timestamp": "2026-03-31T17:00:01Z"}
{"integration": "kubelinter", "check": "latest-tag", "remediation": "Use a container image with a specific tag other than latest.", "message": "The container \"insecure-app\" is using an invalid container image, \"nginx:latest\". Please use images that are not blocked by the `BlockList` criteria : [\".*:(latest)$\" \"^[^:]*$\" \"(.*/[^:]+)$\"]", "kind": "Deployment", "name": "insecure-app", "namespace": "frontend", "file": "/etc/kubernetes/manifests/insecure-deployment.yaml", "scan_timestamp": "2026-03-31T17:00:01Z"}
...

The log file should contain one JSON object per line, each representing a single KubeLinter finding. Each object contains the check name, the failed object, and the remediation recommendation.

  1. Create a cron job to run the scan every 30 minutes:
# echo "*/30 * * * * root /usr/local/bin/kubelinter-scan.sh" \
  | tee /etc/cron.d/kubelinter-scan

Note

Adjust the cron schedule to match your organization’s scanning frequency. For environments where manifests change frequently, consider using a shorter interval.

Configure the Wazuh agent

Configure the Wazuh agent to monitor the KubeLinter log file so findings are forwarded to the Wazuh server for analysis and alerting.

  1. Add the following configuration to the <ossec_config> block of the /var/ossec/etc/ossec.conf file to monitor the KubeLinter log file.
<ossec_config>
  <localfile>
    <log_format>json</log_format>
    <location>/var/log/kubelinter.log</location>
  </localfile>
</ossec_config>
  1. Restart the Wazuh agent to apply the changes:
# systemctl restart wazuh-agent

Wazuh dashboard

Create custom rules

Logs are provided in JSON format, so the built-in Wazuh JSON decoder can parse them without requiring custom decoders. Perform the steps below to add rules to the Wazuh server for analysis.

  1. Navigate to Server management > Rules.
  2. Click + Add new rules file.
  3. Copy and paste the rules below and name the file kubelinter_rules.xml.
<group name="kubelinter,">
  <!-- Parent rule: matches KubeLinter JSON findings -->
  <rule id="100000" level="0">
    <decoded_as>json</decoded_as>
    <field name="integration">^kubelinter$</field>
    <description>KubeLinter: Kubernetes misconfiguration finding detected.</description>
  </rule>

  <!-- Secret exposed as environment variable -->
  <rule id="100001" level="12">
    <if_sid>100000</if_sid>
    <field name="check">^env-var-secret$</field>
    <description>KubeLinter: $(kind) "$(name)" in namespace "$(namespace)" exposes a secret as a plain environment variable. Manifest: $(file).</description>
    <mitre>
      <id>T1552.007</id>
    </mitre>
  </rule>

  <!-- Latest tag -->
  <rule id="100002" level="7">
    <if_sid>100000</if_sid>
    <field name="check">^latest-tag$</field>
    <description>KubeLinter: $(kind) "$(name)" in namespace "$(namespace)" uses an unpinned container image tag. Manifest: $(file).</description>
    <mitre>
      <id>T1204.003</id>
    </mitre>
  </rule>

  <!-- Writable root filesystem -->
  <rule id="100003" level="10">
    <if_sid>100000</if_sid>
    <field name="check">^no-read-only-root-fs$</field>
    <description>KubeLinter: $(kind) "$(name)" in namespace "$(namespace)" has a container with a writable root filesystem. Manifest: $(file).</description>
    <mitre>
      <id>T1611</id>
    </mitre>
  </rule>

  <!-- Privilege escalation allowed -->
  <rule id="100004" level="13">
    <if_sid>100000</if_sid>
    <field name="check">^privilege-escalation-container$</field>
    <description>KubeLinter: $(kind) "$(name)" in namespace "$(namespace)" allows privilege escalation. Manifest: $(file).</description>
    <mitre>
      <id>T1611</id>
    </mitre>
  </rule>

  <!-- Privileged container -->
  <rule id="100005" level="14">
    <if_sid>100000</if_sid>
    <field name="check">^privileged-container$</field>
    <description>KubeLinter: $(kind) "$(name)" in namespace "$(namespace)" is running in privileged mode. Manifest: $(file).</description>
    <mitre>
      <id>T1611</id>
    </mitre>
  </rule>

  <!-- Not running as non-root -->
  <rule id="100006" level="10">
    <if_sid>100000</if_sid>
    <field name="check">^run-as-non-root$</field>
    <description>KubeLinter: $(kind) "$(name)" in namespace "$(namespace)" is not set to run as a non-root user. Manifest: $(file).</description>
    <mitre>
      <id>T1611</id>
    </mitre>
  </rule>

  <!-- No CPU requests -->
  <rule id="100007" level="6">
    <if_sid>100000</if_sid>
    <field name="check">^unset-cpu-requirements$</field>
    <description>KubeLinter: $(kind) "$(name)" in namespace "$(namespace)" has no CPU requests set. Manifest: $(file).</description>
    <mitre>
      <id>T1499</id>
    </mitre>
  </rule>

  <!-- No memory limits -->
  <rule id="100008" level="6">
    <if_sid>100000</if_sid>
    <field name="check">^unset-memory-requirements$</field>
    <description>KubeLinter: $(kind) "$(name)" in namespace "$(namespace)" has no memory limits set. Manifest: $(file).</description>
    <mitre>
      <id>T1499</id>
    </mitre>
  </rule>
</group>

Where:

  • Rule ID 100000 is the parent rule that matches all KubeLinter log entries. It is set to level 0, so it does not generate any alerts on its own.
  • Rule ID 100001 detects workloads that expose secrets in environment variables.
  • Rule ID 100002 detects workloads that use an unpinned container image tag, such as latest.
  • Rule ID 100003 detects workloads that allow a writable root filesystem in the container.
  • Rule ID 100004 detects workloads that allow privilege escalation in the container security context.
  • Rule ID 100005 detects workloads that run containers in privileged mode.
  • Rule ID 100006 detects workloads that are not configured to run as a non-root user.
  • Rule ID 100007 detects workloads that do not define CPU requests.
  • Rule ID 100008 detects workloads that do not define memory limits.
  1. Click Save and Reload when prompted to apply the changes.

Alerts visualization

Generating events

Test the detection by manually executing the /usr/local/bin/kubelinter-scan.sh script on the Ubuntu endpoint:

# /usr/local/bin/kubelinter-scan.sh

Visualizing alerts on the Wazuh dashboard

Perform the following steps to confirm alerts are generated on the Wazuh dashboard:

  1. Navigate to > Threat intelligence > Threat Hunting and click the Events tab.
  2. Click + Add filter. Then filter by rule.groups.
  3. Select is in the Operator field.
  4. Search and select kubelinter in the Values field.
  5. Click Save.

The image below shows KubeLinter alerts generated on the Wazuh dashboard. 

Alerts visualitazion

Conclusion

KubeLinter identifies Kubernetes misconfigurations early, while Wazuh provides the centralized visibility, correlation, and alerting that security teams require. By combining KubeLinter with Wazuh log monitoring and alerting capabilities, organizations gain continuous insight into configuration risks without relying only on manual execution or CI/CD enforcement.

Kubernetes misconfigurations are easy to introduce and often go undetected until they are exploited. Adding static manifest analysis to your pipeline helps catch these issues before deployment, while integrating KubeLinter findings into Wazuh gives security teams centralized, continuous visibility into the security posture of your Kubernetes environment.

This integration is most effective for detecting misconfigurations visible in YAML files. You can pair this integration with Kubernetes audit log monitoring in Wazuh for runtime visibility and with a container image scanner, such as Trivy, for broader Kubernetes security coverage. For more information, see our blog posts on  Auditing Kubernetes with Wazuh and Container image security with Wazuh and Trivy

To learn more about Wazuh, explore our other blog posts, and join the growing community.

References