Scan Kubernetes YAML with Kubesec and KubeLinter

Scan Kubernetes YAML with Kubesec and KubeLinter, compare findings, lint rendered Helm and Kustomize manifests, enforce CI checks, and verify remediation.

Published

Updated

Read time 12 min read

Reviewed byDeepak Prasad

Kubesec and KubeLinter scanning a Kubernetes Deployment manifest for privileged containers, host network, and missing resource limits before deployment
Tested on Rocky Linux 10.2 (Red Quartz) workstation
Package kubesec 2.14.2
kube-linter 0.8.3
helm 3.21.3
kubectl 1.36.3
jq 1.7.1
Applies to Linux x86_64 workstations
Cert prep CKS
Lab environment Workstation with kubectl and Helm — optional kubeadm cluster from install Kubernetes with kubeadm for applying hardened manifests after scan
Privilege Normal user for manifest scans; sudo to install binaries under /usr/local/bin
Scope Kubesec and KubeLinter install, weak Deployment lab manifest, tool output interpretation, comparison table, directory scans, rendered Helm and Kustomize linting, custom KubeLinter checks, CI artifact guidance, remediation and rescan, and scanner limitations. Does not cover Falco runtime detection, image vulnerability scanning, full OPA or Gatekeeper policy, Helm or Kustomize tutorials, or admission enforcement.
Related guides Kubernetes Pod Security Standards
Build secure minimal container images
Harden Kubernetes API server access

Manifest scanners catch risky Kubernetes YAML before it reaches admission or the cluster. This walkthrough installs Kubesec 2.14.2 and KubeLinter 0.8.3 on Rocky Linux 10.2, scans one deliberately weak Deployment, compares how each tool reports findings, remediates the manifest, and rescans rendered Helm and Kustomize output in CI-style pipelines.

IMPORTANT
This guide covers static manifest analysis with Kubesec and KubeLinter. It does not replace Trivy image scanning, Cosign signing, or admission webhooks. Use manifest scans as an early gate in the supply chain, not the only gate.

What manifest scanners can detect

Static analyzers read YAML declarations. They cannot observe runtime behavior, but they flag common misconfigurations:

  • Privileged containers and privilege escalation
  • Root execution (runAsUser: 0)
  • Missing CPU and memory limits or requests
  • Writable root filesystem
  • Added capabilities such as NET_RAW
  • hostNetwork, hostPID, or hostIPC
  • Unsafe hostPath volumes (including Docker socket mounts)
  • Missing liveness or readiness probes
  • Mutable image tags such as :latest
  • Missing standard labels

Kubesec and KubeLinter use different rule sets and severity models. Run both when you want a scored security review plus configurable lint gates in CI.


Install Kubesec and KubeLinter

Pin tool versions in CI and on workstations:

bash
KUBESEC_VERSION=2.14.2
curl -sfL "https://github.com/controlplaneio/kubesec/releases/download/v${KUBESEC_VERSION}/kubesec_linux_amd64.tar.gz" | tar xz
sudo mv kubesec /usr/local/bin/kubesec
bash
KUBE_LINTER_VERSION=0.8.3
curl -sfL "https://github.com/stackrox/kube-linter/releases/download/v${KUBE_LINTER_VERSION}/kube-linter-linux.tar.gz" | tar xz
sudo mv kube-linter /usr/local/bin/kube-linter

Confirm versions before you scan release manifests:

bash
kubesec version
output
version 2.14.2
bash
kube-linter version
output
0.8.3

The CI gate script later in this guide uses jq to parse Kubesec JSON. Install and verify it:

bash
sudo dnf install -y jq
jq --version
output
jq-1.7.1

Create an intentionally weak manifest

Save the weak manifest as weak.yaml:

bash
mkdir -p ~/manifest-scan-lab && cd ~/manifest-scan-lab
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: weak-web
  namespace: manifest-scan-lab
spec:
  replicas: 1
  selector:
    matchLabels:
      component: web
  template:
    metadata:
      labels:
        component: web
    spec:
      hostNetwork: true
      containers:
        - name: nginx
          image: docker.io/library/nginx:latest
          ports:
            - containerPort: 80
          securityContext:
            privileged: true
            runAsUser: 0
            readOnlyRootFilesystem: false
            capabilities:
              add:
                - NET_RAW
          volumeMounts:
            - name: host-docker
              mountPath: /var/run/docker.sock
      volumes:
        - name: host-docker
          hostPath:
            path: /var/run/docker.sock
            type: Socket

The manifest omits app.kubernetes.io/* labels, resource limits, probes, and a read-only root filesystem. It enables hostNetwork, privileged, root execution, NET_RAW, a Docker socket hostPath, and the nginx:latest tag from Docker Hub.


Run Kubesec

Scan the weak manifest locally:

bash
kubesec scan weak.yaml

Kubesec prints JSON for each object. Read these fields:

Field Meaning
object API kind, name, and namespace
score Numeric result combining critical deductions and missed advisories
message Pass or fail summary
scoring.critical High-impact misconfigurations with point deductions
scoring.advise Hardening opportunities that add points when present
scoring.passed Controls already satisfied

Trimmed output for the weak Deployment:

output
[
  {
    "object": "Deployment/weak-web.manifest-scan-lab",
    "valid": true,
    "message": "Failed with a score of -48 points",
    "score": -48,
    "scoring": {
      "critical": [
        {
          "id": "Privileged",
          "selector": "containers[] .securityContext .privileged == true",
          "reason": "Privileged containers can allow almost completely unrestricted host access",
          "points": -30
        },
        {
          "id": "HostNetwork",
          "selector": ".spec .hostNetwork == true",
          "reason": "Sharing the host's network namespace permits processes in the pod to communicate with processes bound to the host's loopback adapter",
          "points": -9
        },
        {
          "id": "DockerSock",
          "selector": "volumes[] .hostPath .path == /var/run/docker.sock",
          "reason": "Mounting the docker.socket leaks information about other containers and can allow container breakout",
          "points": -9
        }
      ],
      "advise": [
        {
          "id": "RunAsNonRoot",
          "selector": ".spec, .spec.containers[] | .securityContext .runAsNonRoot == true",
          "reason": "Force the running image to run as a non-root user to ensure least privilege",
          "points": 1
        }
      ]
    }
  }
]

Kubesec exits with code 2 by default when any scanned object has a score of 0 or lower. The failure code can be changed with --exit-code. Treat scoring.critical items as blocking findings; use advisories for backlog hardening. Do not use the total score alone as your only CI gate.


Run KubeLinter

Scan the same file:

bash
kube-linter lint weak.yaml
output
KubeLinter 0.8.3

... container "nginx" ... (check: docker-sock, remediation: ...)
... container "nginx" has NET_RAW capability added (check: drop-net-raw-capability, remediation: ...)
... pod is using host network (check: host-network, remediation: ...)
... container "nginx" is using latest tag (check: latest-tag, remediation: ...)
... container "nginx" does not have a read-only root filesystem (check: no-read-only-root-fs, remediation: ...)
... container "nginx" allows privilege escalation (check: privilege-escalation-container, remediation: ...)
... container "nginx" is privileged (check: privileged-container, remediation: ...)
... container "nginx" is not set to runAsNonRoot (check: run-as-non-root, remediation: ...)
... container "nginx" has no CPU requirement (check: unset-cpu-requirements, remediation: ...)
... container "nginx" has no memory requirement (check: unset-memory-requirements, remediation: ...)

Error: found 11 lint errors

KubeLinter exits 1 when any check fails. Each line names the check, affected object, container, remediation hint, and file path.


Compare the tools

Aspect Kubesec KubeLinter
Model Security scoring with critical deductions Named lint checks with remediation text
Focus Security controls and score Security plus operational hygiene
Strength Quick scored review of one object Directory, chart, and stdin scans
Severity Points and scoring.critical list Per-check pass or fail; enable, disable, or exclude checks in kube-linter-policy.yaml
CI gate Use explicit critical IDs, not score alone Non-zero exit code on failed checks

Run Kubesec when you want a scored snapshot. Run KubeLinter when you want named checks you can enable, disable, or extend in CI.


Scan a directory

KubeLinter accepts a directory path. Kubesec 2.14.2 accepts one file or standard input—it cannot read a directory as a single input.

Copy only the weak manifest into a scan folder for the first pass:

bash
mkdir -p manifests
cp weak.yaml manifests/
bash
kube-linter lint manifests/
output
Error: found 11 lint errors

Parser errors appear separately from policy findings—fix YAML syntax first, then interpret lint results. After you create hardened.yaml in the remediation section below, copy it into the same folder and rescan.


Add custom KubeLinter checks

Require an app label across workloads with a small config file:

yaml
checks:
  addAllBuiltIn: true
  exclude:
    - default-service-account
    - sorted-keys
    - minimum-three-replicas
    - dnsconfig-options
    - no-node-affinity
    - no-rolling-update-strategy
    - non-isolated-pod
    - required-annotation-email
    - required-label-owner
    - restart-policy
    - use-namespace
customChecks:
  - name: approved-registry-only
    template: latest-tag
    params:
      allowList:
        - "^quay\\.io/"
        - "^registry\\.k8s\\.io/"
        - "^nginxinc/"
  - name: required-app-kubernetes-io-name
    template: required-label
    params:
      key: app.kubernetes.io/name

Save as kube-linter-policy.yaml in the scan directory, then lint the weak manifest:

bash
kube-linter lint \
  --config kube-linter-policy.yaml \
  weak.yaml
output
... invalid container image, "docker.io/library/nginx:latest" ... (check: approved-registry-only, ...)
... no label matching "app.kubernetes.io/name=<any>" found (check: required-app-kubernetes-io-name, ...)
Error: found 17 lint errors

This lab excludes default-service-account because the workload disables automatic token mounting and ServiceAccount design is outside this manifest-scanning example. In a stricter policy, create a dedicated ServiceAccount and set serviceAccountName instead.

Keep custom checks focused. Registry allow lists, required labels, and disallowed Service types cover most team policies without building a full policy framework. KubeLinter automatically loads .kube-linter.yaml from the current working directory when --config is omitted—use an explicit filename such as kube-linter-policy.yaml so default-rule demonstrations are not affected.


Scan rendered Helm output

Unresolved Helm templates hide final securityContext and image values. Create a minimal chart, render it, then lint:

bash
mkdir -p helm-chart/templates

helm-chart/Chart.yaml:

yaml
apiVersion: v2
name: demo
version: 0.1.0

helm-chart/values.yaml:

yaml
replicaCount: 1
image:
  repository: docker.io/library/nginx
  tag: latest

helm-chart/templates/deployment.yaml:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Chart.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: {{ .Chart.Name }}
  template:
    metadata:
      labels:
        app: {{ .Chart.Name }}
    spec:
      containers:
        - name: nginx
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - containerPort: 80
bash
helm template demo ./helm-chart | kube-linter lint -
output
Error: found 5 lint errors

The rendered chart exposes the final nginx:latest value and triggers applicable default checks for the mutable tag, missing resource requirements, non-root execution, privilege escalation, and read-only root filesystem.

With the team policy from the previous section:

bash
helm template demo ./helm-chart |
  kube-linter lint --config kube-linter-policy.yaml -
output
Error: found 10 lint errors

Scan Kustomize output

Apply the same rule to Kustomize overlays. This lab uses a base Deployment with readOnlyRootFilesystem: true plus a prod overlay that weakens it:

bash
mkdir -p \
  kustomize/base \
  kustomize/overlay/prod

kustomize/base/deployment.yaml:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: docker.io/library/nginx:latest
          ports:
            - containerPort: 80
          securityContext:
            readOnlyRootFilesystem: true

kustomize/base/kustomization.yaml:

yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml

kustomize/overlay/prod/patch.yaml:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  template:
    spec:
      containers:
        - name: nginx
          securityContext:
            readOnlyRootFilesystem: false

kustomize/overlay/prod/kustomization.yaml:

yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patches:
  - path: patch.yaml
bash
kubectl kustomize kustomize/overlay/prod | kube-linter lint -
output
Error: found 5 lint errors

The prod overlay changes readOnlyRootFilesystem from true to false, so the rendered manifest still fails the no-read-only-root-fs check.

bash
kubectl kustomize kustomize/overlay/prod |
  kube-linter lint --config kube-linter-policy.yaml -
output
Error: found 10 lint errors

Scan every production overlay. A patch that weakens the base securityContext still fails lint when you render the effective manifest.


CI integration

Archive these fields with every pipeline run:

  • Tool versions (kubesec version, kube-linter version)
  • Config file hash (kube-linter-policy.yaml)
  • Input path or rendered manifest artifact
  • Exit codes from each scanner
  • JSON or text report files

Example pattern:

bash
mkdir -p reports/kubesec
status=0

while IFS= read -r -d '' file; do
  report="reports/kubesec/$(basename "${file%.*}").json"

  # Keep Kubesec from failing only because of its numeric score.
  if ! kubesec scan --exit-code 0 "$file" > "$report"; then
    status=1
    continue
  fi

  # Require valid YAML and no critical Kubesec findings.
  if ! jq -e \
    'all(.[];
      .valid == true and
      (((.scoring.critical // []) | length) == 0)
    )' "$report" >/dev/null; then
    status=1
  fi
done < <(
  find manifests -type f \
    \( -name '*.yaml' -o -name '*.yml' \) -print0
)

if ! kube-linter lint \
  --config kube-linter-policy.yaml \
  --format json \
  manifests/ > reports/kube-linter.json; then
  status=1
fi

exit "$status"

Kubesec reads one file or standard input and uses the configured failure code when a score is zero or lower. KubeLinter returns a non-zero exit code when checks fail. Kubesec 2.14.2 does not accept a directory path—loop over individual files instead. The jq filter blocks on invalid reports and scoring.critical findings rather than the total score.

Pin KUBESEC_VERSION and KUBE_LINTER_VERSION in the pipeline definition. Render Helm and Kustomize in CI before linting:

bash
helm template demo ./helm-chart |
  kube-linter lint --config kube-linter-policy.yaml -

kubectl kustomize kustomize/overlay/prod |
  kube-linter lint --config kube-linter-policy.yaml -

Remediate and rerun

Save the hardened manifest as hardened.yaml:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hardened-web
  namespace: manifest-scan-lab
  labels:
    app.kubernetes.io/name: nginx
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: nginx
  template:
    metadata:
      labels:
        app.kubernetes.io/name: nginx
    spec:
      automountServiceAccountToken: false
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app.kubernetes.io/name: nginx
                topologyKey: kubernetes.io/hostname
      securityContext:
        runAsNonRoot: true
        runAsUser: 101
        runAsGroup: 101
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: nginx
          image: nginxinc/nginx-unprivileged:1.27-alpine@sha256:28d91bdce70ad09025ea901458fdd149259d8e05982ade79d4ef2c0d9470eb48
          ports:
            - name: http
              containerPort: 8080
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
          securityContext:
            allowPrivilegeEscalation: false
            privileged: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
          livenessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}

Harden the weak manifest by addressing the findings both tools reported. The lab uses the official nginxinc/nginx-unprivileged image with a digest pin because the standard nginx image listens on port 80 and needs writable paths that conflict with a read-only root filesystem and dropped capabilities.

The unprivileged image listens on 8080 and places PID and temporary files under /tmp. podAntiAffinity with two replicas clears the no-anti-affinity check under default KubeLinter rules.

bash
kubesec scan hardened.yaml
output
[
  {
    "object": "Deployment/hardened-web.manifest-scan-lab",
    "message": "Passed with a score of 9 points",
    "score": 9
  }
]

Kubesec exits 0. Advisories such as ApparmorAny may remain under scoring.advise until you add annotations or platform-specific profiles.

Rescan with KubeLinter using the default rules:

bash
kube-linter lint hardened.yaml
output
No lint errors found!

KubeLinter exits 0. Rescan with the team policy:

bash
kube-linter lint \
  --config kube-linter-policy.yaml \
  hardened.yaml
output
No lint errors found!

Copy the hardened manifest into the directory scan folder and lint both files:

bash
cp hardened.yaml manifests/
kube-linter lint manifests/
output
Error: found 11 lint errors

Only the weak file contributes failures under the default rules. Run the directory again with --config kube-linter-policy.yaml to evaluate both files against the expanded team policy:

bash
kube-linter lint \
  --config kube-linter-policy.yaml \
  manifests/
output
Error: found 17 lint errors

Pair these scans with Pod Security Standards and image policy at deploy time.


Scanner limitations

Static manifest analysis cannot prove:

Gap Why it matters
Runtime behavior Malware or unexpected processes inside the container
Effective RBAC Who can kubectl exec or read Secrets
Network reachability NetworkPolicy enforcement at runtime
Admission outcome Webhooks may deny manifests scanners marked clean
Malicious binaries Supply-chain risk inside the image layer

Use manifest scans to catch obvious YAML mistakes early. Layer image scans, signing, and admission policy for defense in depth.


Troubleshooting

Symptom Likely cause Fix
Kubesec valid: false YAML syntax error Validate with kubectl apply --dry-run=client -f
kubesec scan manifests/ fails Kubesec reads one file or stdin, not directories Loop over files with find
KubeLinter reports zero files Wrong path or empty directory Pass directory or file explicitly
Helm lint passes, prod fails Scanned templates not rendered output Pipe helm template into kube-linter lint -
Custom check never fires Config not passed or wrong working directory Use kube-linter lint --config kube-linter-policy.yaml
Hardened manifest still has Kubesec advisories Optional controls not in YAML Track advisories separately from critical failures
Score improved but CI still fails KubeLinter checks stricter than Kubesec Align CI gates on named KubeLinter checks

What's Next


References


Summary

Kubesec and KubeLinter catch risky Kubernetes YAML before deployment. Scan a weak Deployment locally, read Kubesec critical items and KubeLinter check names, remediate securityContext, resources, probes, image digest, and labels, then rescan rendered Helm and Kustomize output in CI.

The lab moved from Kubesec score -48 (three scoring.critical findings) and 11 KubeLinter errors to score 9 and a clean lint pass on the hardened manifest. Next steps include pairing manifest gates with Trivy image scans and admission policy in your promotion pipeline.


Frequently Asked Questions

1. Should I block CI on Kubesec score alone?

No. Kubesec scores are useful signals but combine deductions and advisories in one number. Use Kubesec to highlight critical misconfigurations, then gate CI on explicit KubeLinter checks or policy rules your team owns.

2. What is the difference between Kubesec and KubeLinter?

Kubesec applies a security scoring model to individual objects and highlights critical controls. KubeLinter runs named lint checks across files, directories, and rendered manifests with configurable check selection and custom checks.

3. Should I scan Helm charts or rendered manifests?

Scan rendered manifests. helm template expands values into concrete Kubernetes objects. Scanning unresolved templates hides real securityContext, image, and volume settings behind template logic.

4. Can manifest scanners replace image vulnerability scanning?

No. Static manifest analysis cannot see CVEs inside container images, runtime process behavior, or effective RBAC and network reachability. Pair manifest scans with Trivy image scans and admission policy.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)