CoursesKubernetes security & hardeningStatic analysis with Kubesec

Static analysis with Kubesec

Score manifests for risk before they ship.

Advanced10 min · lesson 19 of 24

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.

terminal
$ 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.

pod.yaml
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: web
image: registry.internal/web@sha256:9f2a...
resources:
limits: { cpu: "500m", memory: "256Mi" }
securityContext:
privileged: false
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
terminal
$ # 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.

Three checks, three different questions
A workload before it runs
each layer catches what the others cannot see
reads the image
CVE scanner (Trivy)
vulnerable packages baked into the layers
reads the manifest
Static analysis (Kubesec)
risky settings in the YAML you wrote
enforces at create
Admission control
Pod Security Admission or Kyverno refuses the pod at the API server
Kubesec catches the privileged pod during review. Admission control is what stops it when review gets skipped. Real coverage runs all three.

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.

.gitlab-ci.yml
manifest_scan:
image:
name: kubesec/kubesec:v2
entrypoint: [""]
before_script:
- apk add --no-cache jq
script:
- |
score=$(kubesec scan k8s/pod.yaml | jq '.[0].score' || true)
echo "kubesec score: $score"
[ "$score" -ge 5 ] || { echo "below threshold"; exit 1; }
terminal
$ # pipeline stage on a branch that still sets privileged: true
kubesec score: -30
below threshold
$ echo $?
1 # merge blocked
$ # same stage after the securityContext fix lands
kubesec 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.

terminal
$ cat > bad.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: bad }
spec:
containers:
- name: c
image: nginx
securityContext: { 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 FAIL
FAIL

Takeaway

Image scanners miss YAML sins. Static analysis of manifests catches privileged, host namespaces and added capabilities before merge.

Quick check
01Your pipeline stage runs 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?
Incorrect — The score is additive, so a higher bar only raises the price of the same trick. Enough cheap advise items still buy their way past it, and the host namespace is still sitting there.
Correct — An empty critical list is the thing you actually care about. Read it with jq and fail on it first, then let the score separate two manifests that both cleared that bar.
Incorrect — --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.
Incorrect — Admission is the backstop, not the early catch. Drop the CI check and the manifest merges, and the failure only surfaces when someone applies it.
02A teammate's Pod scores -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?
Correct — Kubesec prices findings by blast radius. Hardening you have not switched on yet earns single points, while a setting that hands over the host is priced in tens, so three advise fixes cannot cancel one of them.
Incorrect — That path is how it grades a Deployment, StatefulSet or DaemonSet. Point it at a Pod and it walks spec.securityContext and the container blocks directly.
Incorrect — There is no such ordering. Everything is scored in one pass, which is exactly why a manifest with a live critical finding can still total a positive number.
Incorrect — All three are advise items, and readOnlyRootFilesystem shows up in the passed list once it is set. Admission is a separate gate that enforces settings at the API server.
03Your CI scans 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?
Incorrect — It has no idea a values file exists. Kubesec reasons about the single document you hand it and nothing else in the repository.
Incorrect — It never talks to a cluster. That is what makes it safe on a laptop or on every commit, and it is also why this cannot be the explanation.
Incorrect — The pod template is where the container blocks live, so it reaches them fine. The path changes with the object you point it at, not what gets graded.
Correct — Render first with helm template or kustomize build, then scan the output. The resolved YAML is the only thing Kubesec can reason about.
A number is not a pass
Kubesec's score is additive, so a flat gate like 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.

Related