Static analysis with Kubesec
Score manifests for risk before they ship.
A container with privileged: true sails through every image scan you own. The image can be spotless: zero CVEs (Common Vulnerabilities and Exposures), freshly patched, signed by your pipeline. It still hands an attacker the whole node the second it starts, because privileged switches off the container's isolation from the host. The weakness never lived in the software. It lived in one line of YAML, and no scanner that reads the image will ever find it.
Static analysis is the check for that whole class of mistake. Think of a building inspector who reads the blueprints before anyone pours concrete. A vulnerability scanner asks whether the materials were recalled; the inspector asks whether the design is sound. Is that panel wired without a ground? Are the fire exits where the drawings say they should be? Kubesec is the blueprint reviewer for Kubernetes manifests. It reads your YAML, compares each field against a fixed list of known-dangerous and known-good patterns, and hands back a score with a plain reason for every point it added or took away. It never starts the container and never talks to a cluster, so you can run it on a laptop or on every commit without touching anything live. The rules are public and opinionated, so the same manifest earns the same number on your machine and in the pipeline.
$ kubesec scan pod.yaml[ { "object": "Pod/web.default","valid": true,"score": -30,"scoring": {"critical": [{ "selector": "containers[] .securityContext .privileged == true","reason": "Privileged containers can allow almost completely unrestricted host access" } ],"advise": [{ "selector": ".spec, .spec.containers[] | .securityContext .runAsNonRoot == true","reason": "Force the running image to run as a non-root user to ensure least privilege" } ] } } ]
Read the score, then raise it
The report sorts findings into three lists. critical is the stuff actively putting the node at risk: privileged, host namespaces like hostNetwork or hostPID, an added Linux capability such as SYS_ADMIN. Those carry heavy negative weight; a single privileged costs you thirty points, and so does SYS_ADMIN, because capabilities are how Linux carves up the powers that used to belong wholesale to root, and SYS_ADMIN is the grab-bag that hands most of them back at once. advise is hardening you haven't turned on yet: runAsNonRoot, readOnlyRootFilesystem, dropping all capabilities, a seccomp (secure computing mode) profile, resource limits. Each of those is worth a point or two. Anything you've already set correctly moves into passed. Once you can read the three lists, fixing a manifest is mechanical. Strip out everything in critical, work down the advise list, and the score climbs from negative into positive. The target isn't a big number for its own sake. It's an empty critical list with the advise items you can reasonably apply switched on. Kubesec is grading the same security-context checklist you'd otherwise run through by hand.
spec:securityContext:runAsNonRoot: trueseccompProfile: { type: RuntimeDefault }containers:- name: webimage: registry.internal/web@sha256:9f2a...resources:limits: { cpu: "500m", memory: "256Mi" }securityContext:privileged: falseallowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities:drop: ["ALL"]
$ # same pod, rescanned after the fix: -30 becomes 7$ # (both lists trimmed here; the full passed list holds seven one-point entries)$ kubesec scan pod.yaml[ { "object": "Pod/web.default","valid": true,"score": 7,"scoring": {"critical": [],"passed": [{ "selector": "containers[] .securityContext .capabilities .drop | index(\"ALL\")","reason": "Drop all capabilities and add only those required to reduce syscall attack surface" },{ "selector": ".spec, .spec.containers[] | .securityContext .runAsNonRoot == true","reason": "Force the running image to run as a non-root user to ensure least privilege" },{ "selector": "containers[] .securityContext .readOnlyRootFilesystem == true","reason": "An immutable root filesystem can prevent malicious binaries being added to PATH" } ],"advise": [{ "selector": ".spec .serviceAccountName","reason": "Service accounts restrict Kubernetes API access and should be configured with least privilege" },{ "selector": "containers[] .resources .requests .cpu","reason": "Enforcing CPU requests aids a fair balancing of resources across the cluster" },{ "selector": "containers[] .resources .requests .memory","reason": "Enforcing memory requests aids a fair balancing of resources across the cluster" } ] } } ]
One detail trips people up: Kubesec grades each object on its own. Point it at a Pod and it reads spec.securityContext; point it at a Deployment, StatefulSet, or DaemonSet and it reads the pod template down at spec.template.spec instead. Same rules, different path, and the tool knows which path to walk. A file holding several resources separated by --- is fine too; Kubesec scores each one and returns an array of results. What it can't read is templating. A raw Helm chart full of {{ }} is just placeholders to it, so render the chart first with helm template (or kustomize build) and feed it the resolved YAML. And remember what it never sees: the rest of your cluster. It can't tell you a NetworkPolicy is missing, or that a default it's trusting gets overridden somewhere else. It reasons about the one document in front of it.
Gate it in CI
A scan you have to remember to run is a scan that rots. Wire Kubesec into continuous integration (CI) and fail the merge when the score drops below a threshold, the same way you already gate image scans. Now the privileged pod gets caught in the merge request, by a machine, before anyone applies it to a cluster and before a reviewer has to spot it by eye. This doesn't replace your other supply-chain controls; it sits beside them. The image scanner reads the artifact, Kubesec reads how you're asking to run it, and admission control (Kyverno, or the built-in Pod Security Admission, PSA) refuses anything non-compliant at the API server. Static analysis is the cheap early catch, seconds into a pipeline. Admission is the backstop that still holds when someone skips CI or applies YAML straight from a laptop.
manifest_scan:image:name: kubesec/kubesec:v2entrypoint: [""]before_script:- apk add --no-cache jqscript:- |score=$(kubesec scan k8s/pod.yaml | jq '.[0].score' || true)echo "kubesec score: $score"[ "$score" -ge 5 ] || { echo "below threshold"; exit 1; }
$ # pipeline stage on a branch that still sets privileged: truekubesec score: -30below threshold$ echo $?1 # merge blocked$ # same stage after the securityContext fix landskubesec score: 7$ echo $?0 # merge allowed
The 5 in that gate is a judgment call, and what matters is that it lives in the pipeline file, where changing it takes a merge request someone has to review. Pick the number by scanning the manifests you already ship: if the healthy ones land around 7, like the pod above, a threshold of 5 catches regressions without failing every build on the day you switch it on.
Kubesec's ruleset is fixed and you cannot add to it. That is exactly why the score comes out the same everywhere, but it also means a house rule such as banning every registry except your own belongs in a tool that runs rules you write: Conftest, kube-linter, or a Kyverno admission policy.
Kubesec has no exception file and no way to mute a finding for one workload. The closest it gives you is kubesec scan --rules ..., which limits that single run to the rules you name and applies to every object in the file. So an exception you decide to grant, such as the logging agent that really does mount /var/run/docker.sock, has to be recorded and enforced somewhere else: in the admission policy, or in a separate CI job that scans that one path with its own threshold. Give it an owner and a review date. Loosening the gate across a whole directory recreates the privileged default you just escaped. Write the verification command next to the control in the same pull request, and keep the sample output so the next on-call person can tell pass from fail without guessing.
Try this
Run kubesec (or kube-linter/Checkov) on a privileged manifest and gate CI on the score.
$ cat > bad.yaml <<'EOF'apiVersion: v1kind: Podmetadata: { name: bad }spec:containers:- name: cimage: nginxsecurityContext: { privileged: true }EOF$ docker run -i kubesec/kubesec:v2 scan /dev/stdin < bad.yaml > out.json$ cat out.json[{"object": "Pod/bad.default","score": -30,"scoring": {"critical": [{ "id": "Privileged", "selector": "containers[] .securityContext.privileged == true" }]}}]$ # CI gate example$ test $(jq '.[0].score' out.json) -ge 5 && echo PASS || echo FAILFAIL
Takeaway
Image scanners miss YAML sins. Static analysis of manifests catches privileged, host namespaces and added capabilities before merge.
score=$(kubesec scan k8s/pod.yaml | jq '.[0].score') and then [ "$score" -ge 5 ] || exit 1. A merge request clears that gate while still setting hostNetwork: true, because it also drops ALL capabilities, adds a seccomp profile and sets resource limits. Which change to the stage stops it merging?--rules narrows a whole run to the rules you name, for every object in the file. You would gate on host namespaces and quietly stop noticing privileged.-30. They add resource limits, a seccomp profile and readOnlyRootFilesystem: true, rerun kubesec scan pod.yaml, and the score has barely moved. What explains that?spec.securityContext and the container blocks directly.readOnlyRootFilesystem shows up in the passed list once it is set. Admission is a separate gate that enforces settings at the API server.chart/templates/deployment.yaml straight from a Helm chart. The score has read the same for weeks with an empty critical list, even though one component ships with privileged: true. What is going on?helm template or kustomize build, then scan the output. The resolved YAML is the only thing Kubesec can reason about.score >= 5 is gameable: pile on cheap advise wins such as resource limits and a seccomp profile until they outweigh a hostNetwork: true nobody fixed. Do the arithmetic before you trust that trick, though. Every positive rule added together tops out in the mid-teens, so nothing you switch on buys back a privileged at -30; the negatives it can pay off are the mid-weight ones, host namespaces at -9 each and allowPrivilegeEscalation: true at -7. Gate on an empty critical list first, then treat the number as a tie-breaker. And the scan only reasons about the YAML. It can't tell you the image was built to run as root, which bites the other way: with runAsNonRoot: true set, that pod does not run unsafely, the kubelet refuses to start it and the pod fails on the node. Nor can it tell you whether anything is enforcing that setting once the pod is live.