| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | kubesec 2.14.2kube-linter 0.8.3helm 3.21.3kubectl 1.36.3jq 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.
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, orhostIPC- Unsafe
hostPathvolumes (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:
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/kubesecKUBE_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-linterConfirm versions before you scan release manifests:
kubesec versionversion 2.14.2kube-linter version0.8.3The CI gate script later in this guide uses jq to parse Kubesec JSON. Install and verify it:
sudo dnf install -y jq
jq --versionjq-1.7.1Create an intentionally weak manifest
Save the weak manifest as weak.yaml:
mkdir -p ~/manifest-scan-lab && cd ~/manifest-scan-labapiVersion: 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: SocketThe 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:
kubesec scan weak.yamlKubesec 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:
[
{
"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:
kube-linter lint weak.yamlKubeLinter 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 errorsKubeLinter 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:
mkdir -p manifests
cp weak.yaml manifests/kube-linter lint manifests/Error: found 11 lint errorsParser 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:
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/nameSave as kube-linter-policy.yaml in the scan directory, then lint the weak manifest:
kube-linter lint \
--config kube-linter-policy.yaml \
weak.yaml... 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 errorsThis 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:
mkdir -p helm-chart/templateshelm-chart/Chart.yaml:
apiVersion: v2
name: demo
version: 0.1.0helm-chart/values.yaml:
replicaCount: 1
image:
repository: docker.io/library/nginx
tag: latesthelm-chart/templates/deployment.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: 80helm template demo ./helm-chart | kube-linter lint -Error: found 5 lint errorsThe 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:
helm template demo ./helm-chart |
kube-linter lint --config kube-linter-policy.yaml -Error: found 10 lint errorsScan 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:
mkdir -p \
kustomize/base \
kustomize/overlay/prodkustomize/base/deployment.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: truekustomize/base/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yamlkustomize/overlay/prod/patch.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
template:
spec:
containers:
- name: nginx
securityContext:
readOnlyRootFilesystem: falsekustomize/overlay/prod/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: patch.yamlkubectl kustomize kustomize/overlay/prod | kube-linter lint -Error: found 5 lint errorsThe prod overlay changes readOnlyRootFilesystem from true to false, so the rendered manifest still fails the no-read-only-root-fs check.
kubectl kustomize kustomize/overlay/prod |
kube-linter lint --config kube-linter-policy.yaml -Error: found 10 lint errorsScan 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:
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:
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:
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.
kubesec scan hardened.yaml[
{
"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:
kube-linter lint hardened.yamlNo lint errors found!KubeLinter exits 0. Rescan with the team policy:
kube-linter lint \
--config kube-linter-policy.yaml \
hardened.yamlNo lint errors found!Copy the hardened manifest into the directory scan folder and lint both files:
cp hardened.yaml manifests/
kube-linter lint manifests/Error: found 11 lint errorsOnly 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:
kube-linter lint \
--config kube-linter-policy.yaml \
manifests/Error: found 17 lint errorsPair 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
- Secure Container Image Promotion in CI/CD
- kubectl logs, Events and describe with Examples
- Debug Kubernetes Pods with kubectl exec and kubectl debug
References
- Kubesec documentation
- KubeLinter documentation
- Kubernetes Pod Security Standards — upstream workload security profiles
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.

