Skip to content
Back to skills

K8s Render Scan

BSecurity

Offline per-environment render of Kustomize overlays and Helm values; finds Helm, Kustomize and GitOps-spec defects.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentsgoshellbashreactnodekubernetesazuregitapidevops

Works with

  • api

Security analysis

B75/100
  • criticalAccesses sensitive system or user directories

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill k8s-render-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of K8s Render Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for K8s Render Scan
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-k8s-render-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-k8s-render-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: k8s-render-scan
description: Offline per-environment render of Kustomize overlays and Helm values; finds Helm, Kustomize and GitOps-spec defects.
kind: skill
pack: infrastructure
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: k8s-render-scan

## Premise

What a cluster runs is the render, not the source. A Kustomize patch whose `target` names nothing applies nothing and exits 0. A Helm values key with a typo is dropped without a word, and `helm lint --strict` passes it. A `commonLabels` edit rewrites an immutable selector, so the next apply moves the Service and then fails on the Deployment. An Argo CD Application with `selfHeal` puts back the replica count the HPA just raised. Every one of these is invisible in the file that carries it, and every one reads as clean to a reviewer and to the usual scanners.

This skill does two jobs:

- **§ 0, the render contract**, is the shared first step of every Kubernetes detector in this pack (`k8s-availability-scan`, `k8s-wiring-scan`, `k8s-api-version-scan` read its output). `scripts/render.sh` (beside this file, installed as `.claude/skills/k8s-render-scan/scripts/render.sh`; bash 3.2 and zsh, read-only, offline) renders every Kustomize root and every chart × values file **per environment**. Each object is tagged with its source file, so a finding cites `<file:line>` in the source, never a line of the render.
- **§ 1–§ 5** find the defects that live in the rendering machinery itself: Helm values and behaviour, Kustomize patches and transformers, the static GitOps spec, and selector mutation between the base branch and head.

**Every finding cites the source `<file:line>` + the environment whose render shows it + the CD target that deploys that environment (Argo Application, Flux Kustomization, deploy job, runbook line) + the oracle that proved it + the fix in the source.** A grep hit, a jq row or a tool message is a candidate. "The render is clean" is never printed without the ledger and the class-coverage block (§ Output). Verification follows [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/infrastructure.md`](../../../../snippets/precedents/infrastructure.md). The oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Infrastructure, variants *Render matrix*, *Patch no-op/phantom*, *SSA / upgrade twice*, *Selector mutation* and *GitOps spec*.

## The rule

A candidate is a finding when all four hold (the seven gates as `precedents/infrastructure.md` reads them for infrastructure):

1. **The environment is deployed.** A CD target, deploy job or runbook line applies the overlay, values file or Application that shows the defect (gate 1). A dev overlay nothing deploys is Hardening (precedent 7).
2. **The trigger occurs.** The next sync, upgrade, scale event or label edit makes the defect bite (gate 2).
3. **No control already handles it.** `RespectIgnoreDifferences=true` (precedent 14), a hash-suffixed generator or Reloader (precedent 13), a `Capabilities` guard (precedent 12), a `values.schema.json` with `additionalProperties: false` (gate 4).
4. **The oracle fired on the versions the files name.** Helm major, Kustomize version and target Kubernetes minor are read from files (§ Version gate), never assumed (gate 3).

## Boundaries

- **Replica floors, PDBs, HPA and KEDA shape, probes, rollout, shutdown timing, platform single points of failure** → `k8s-availability-scan` (this pack), against `THR-K8S-PROD-FLOOR` (`governance/thresholds.json`). Its § 4 owns K-HPA-REPLICAS on plain manifests; here the same root cause is graded only in its Helm (§ 2), Kustomize (§ 3) and GitOps (§ 4) forms.
- **Service and port wiring, config propagation (K-CONFIG-ROLL), NetworkPolicy paths, Jobs, RBAC wiring** → `k8s-wiring-scan` (this pack). § 3b finds the generator setting; the consumer's rollout gap is graded there.
- **Removed APIs, schema and CRD validity, admission rejection** → `k8s-api-version-scan` (this pack). § 0's canaries are shared with it.
- **Unpinned charts and platform components** (PIN-HELM: `helm upgrade --install` with no `--version`, Argo `targetRevision: HEAD`) → `iac-state-scan` § 3 (this pack). § 0's `inventory-platform.tsv` is its input.
- **Live drift and reconciliation** (what Argo or Flux has actually applied) → devops `gitops-audit` when the devops pack is installed; without it, list the rows as `N/A (owner pack devops not installed)`. This skill owns the static spec only.
- **Privilege** (RBAC breadth, ingress snippets, mutable image tags, secrets in values files) → security `iac-scan` § 3 and `secret-scan-method` when the security pack is installed. Without it, tag the row `security-class (owner pack not installed)`, list it and do not grade it.
- **PromQL content of a PrometheusRule** → observability `alert-rule-lint` when that pack is installed; without it the rule's content is not judged here, and the row reads `N/A (owner pack observability not installed)`.

## Version gate (gate 3), read from files

| Version | Where to read it | Why it changes the verdict |
|---|---|---|
| Helm major | CI (`azure/setup-helm` `version:`, a `helm:` image tag, `HELM_VERSION`), the runbook's install line, `.tool-versions`, `mise.toml` | Helm 4 applies server-side by default on new installs, renamed `--atomic` to `--rollback-on-failure` and `--force` to `--force-replace`, and turned `--wait` into a strategy flag (`helm upgrade --help`, v4.2.4, read 2026-10-10). K-HELM-FLAGS and K-HELM-SSA exist only under Helm 4. |
| Target Kubernetes minor | Argo `destination` plus cluster docs, the runbook's cluster-create `--version`, a kind `node` image, `kubeVersion` in `Chart.yaml`, `--kube-version` in CI | `Capabilities` branches render differently; pass it to `render.sh --kube-version`. |
| Kustomize | CI (`kustomize` release or `kubectl` version, which embeds one) | `commonLabels` is deprecated since Kustomize 5.0. |
| Argo CD / Flux | the controller chart pin or image tag in the platform inventory | `RespectIgnoreDifferences`, SSA diffing and prune defaults moved across minors; an unread version makes § 4 Unconfirmed. |

No version found → the version-gated rows are Unconfirmed, with the probe written out (`helm version --short` in the CI image; the cluster's minor from the provider console).

## § 0. Render contract (shared by every Kubernetes detector)

```bash
SCAN="$(mktemp -d "${TMPDIR:-/tmp}/k8s.XXXXXX")"
bash .claude/skills/k8s-render-scan/scripts/render.sh . --out="$SCAN" --kube-version=<target minor from the version gate>
# --self-test proves it on a built-in fixture (32 checks: per-env counts, source tags incl. a chart directory not named after its chart, a blocked remote base, the inventory, the canaries).
```

| Output | What it holds | Who reads it |
|---|---|---|
| `render-<env>.yaml` | the render as it would apply, documents joined by an explicit `---` (a Kustomize render does not start with one; `cat a b` glues two objects and a linter reports a duplicate key) | the tool lane |
| `render-<env>.json` | the same objects; each carries `__src` {env, origin `base`/`overlay`/`chart`/`argo`/`raw`, root, file, line, values}. Kustomize lines come from the cited file: the document's first key, or the generator entry; no match leaves `line` null (a file-only citation, never a guessed line) | every jq join; the citation |
| `render-ledger.tsv` | env · origin · objects · exit · stderr lines · root | the ledger line of every k8s detector |
| `render.err` | every renderer's stderr, prefixed `[env]` | § 3a (deprecations), § 0 failures |
| `inventory-platform.tsv` | each `helm install/upgrade/template` and `kubectl apply/create -f/-k` line in runbooks, scripts, Makefiles and CI, with release, chart, version, `UNPINNED` (no `--version`) or `UNREAD(<var>)` (a version passed through a variable or array the file does not set: a pin until read, precedents § 63), values files, namespace and flags | `iac-state-scan` § 3, `k8s-availability-scan` § 9, § 2f |
| `cd-targets.tsv` | Argo Applications and ApplicationSets, Flux Kustomizations and HelmReleases, with sync policy and the env each one deploys | § 4; the "is it deployed" gate for every detector |
| `embedded/src/`, `embedded/<env>/` | edge configs found inside YAML, line-preserved from the source, and as each env renders them | `edge-reliability-scan` |
| `canary.yaml` | a `policy/v1beta1` PodDisruptionBudget and a ConfigMap named `n` | every tool run |

**How `render.sh` behaves**:

- **Offline.** `KUBECONFIG` points at an empty scratch file and Helm's homes at scratch directories, so no command can reach the caller's context or repo credentials.
- **Read-only.** It never writes in the repo and refuses an `--out` inside it.
- **Remote bases are not fetched.** Such a root reads `blocked (remote base)` unless the user consents to `--allow-remote`.
- **Unloaded manifests are rendered too.** A manifest no kustomization loads becomes a `raw__<dir>` env, tagged when it sits in a kustomize dir, and the inventory names the runbook line that applies it.
- **Argo charts render with the Application's values.** An Argo Application whose `path` is a chart in this repo renders as `argo__<app>` with its `valueFiles`.

**Ledger rules:**

1. **Count objects, not exit codes.** Every tool that reads the render prints the objects it read against `objects + 2` (the canaries). A tool that read fewer is NOT-RUN for the difference.
2. **Read the canaries in the tool's output.** The `policy/v1beta1` canary must come back as an error, never `skipped`: kubeconform `-ignore-missing-schemas` skips it with exit 0, and kube-linter drops it. The ConfigMap canary must come back named `n`. A tool that names it `false`, or omits it, decodes YAML 1.1 and misreads every `on`/`off`/`yes`/`no` value in the render.
3. **Read `render.err`, and treat a failed CD-target render as a finding.** It is high under this skill: the deploy cannot happen. Every other detector's row for that env reads `blocked (render failed: <env>)`.

**Read step.**

- Which env is the prod CD target? Read `cd-targets.tsv`, the deploy job and the runbook line.
- Which raw envs are applied by hand, and from which runbook line?
- Which roots are blocked, and why?
- Which inventory components sit on the request path? That question feeds `k8s-availability-scan` § 9.

**Measured 2026-10-10** (kustomize 5.8.3, helm 4.2.4, yq 4.54.1). The real repo was a NestJS/React monorepo with a Kustomize base, a managed-cloud overlay and runbook-driven platform installs; it is not named here.

| Target | Result |
|---|---|
| Base and overlay | 26 / 25 objects, exit 0, empty stderr; every object's `__src.file` resolved to its base file, and (re-run the same day after the line fill) all 51 objects carry `__src.line` |
| Unloaded manifests | two raw envs (2 and 7 objects): manifests the runbook applies by hand |
| Platform inventory | 16 `helm` lines: first run 14 `UNPINNED`; after variable and array resolution 13 `UNPINNED`, 2 `UNREAD` (one version from a command substitution reading the deployed release, one from a loop variable), 1 pinned (the PIN-HELM input); 22 `kubectl apply` lines |
| Embedded configs | 2 extracted from the source (a Caddyfile ConfigMap and an ingress snippet) and 2 per env |
| kubeconform 0.8.0 `-strict` with the canaries | 26 + 2 and 25 + 2 objects read. `refract-canary-removed-api` came back `statusError`; the ConfigMap canary came back as `ConfigMap/false`, so kubeconform decodes YAML 1.1. |
| pluto 5.24.4 `--target-versions k8s=v1.36.0` | flagged the canary only |
| Research fixture (a Kustomize base with two overlays, two charts, a raw multi-document file) | 23 + 3 + 3 objects. The deprecated `commonLabels` warning was captured for all 3 Kustomize roots. A chart whose `values.schema.json` rejects the typo key failed with exit 1, and that failure is recorded. |
| `benchmarks/fixtures/*` | 2 of 15 hold Kubernetes YAML (raw manifests: 5 and 2 objects); 0 kustomize or Helm roots |

## Pre-flight greps

Run as **one** script from the repo root after § 0 (shell state does not survive between tool calls). Grep **N** feeds **§ N**. Every `rg` names its path. A lane that prints `No files were searched` is absent, which is an `N/A` reason. An `rg` error (exit 2) is a failed grep, never zero hits. Steps 3d and 5 mutate **copies under `$SCAN` only**.

```bash
SCAN="${SCAN:?render first (§ 0)}"; RS="${RS:-.claude/skills/k8s-render-scan/scripts/render.sh}"
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/.git/**' -g '!**/{testdata,__tests__,fixtures}/**' -g '!**/{docs,locales,i18n,translations}/**' -g '!ai/**')
KZ=(-g 'kustomization.y*ml' -g 'Kustomization')
J() { find "$SCAN" -maxdepth 1 -name 'render-*.json' -exec jq -c '.[]' {} +; }   # every rendered object, one per line, each with __src
# An object an HPA or a KEDA ScaledObject scales that still renders spec.replicas (2d, 3f and 4a share it).
HR='group_by(.__src.env)[] | . as $E | map(select(.kind == "HorizontalPodAutoscaler" or .kind == "ScaledObject")) as $A
  | $E[] | select(.spec.replicas != null) | . as $w | select(any($A[]; .spec.scaleTargetRef.name == $w.metadata.name
      and (.spec.scaleTargetRef.kind // "Deployment") == $w.kind and (.metadata.namespace // "") == ($w.metadata.namespace // "")))
  | "HPA-TARGET-REPLICAS\t\($w.__src.env)\t\($w.__src.origin)\t\($w.kind)/\($w.metadata.name) replicas \($w.spec.replicas)\t\($w.__src.file)"'

# 0. Context: failed or blocked renders, renderer stderr, CD targets (with the env each deploys).
awk -F'\t' 'NR > 1 && $4 != 0' "$SCAN/render-ledger.tsv"; rg -n -i 'deprecat|error|warn' "$SCAN/render.err"; awk -F'\t' 'NR > 1' "$SCAN/cd-targets.tsv"

# 1. Helm values ↔ templates per chart × values file: the shallowest key no template reads (1a); a template path values.yaml
#    does not define (1b; `guarded` = behind default/required/if/with — a guarded ref beside a 1a key of a similar name is a typo pair).
rg --files . -g 'Chart.yaml' "${X[@]}" | while IFS= read -r ch; do C="$(dirname "$ch")"; [ -d "$C/templates" ] || continue
  rg -o -N --no-filename --pcre2 '\$?\.Values(?:\.[A-Za-z0-9_]+)+' "$C/templates" | sed 's/^\$//; s/^\.Values\.//' | sort -u > "$SCAN/.refs"
  sub="$(yq -r '[.dependencies[]? | (.alias // .name)] + ["global"] | join(" ")' "$ch")"
  find "$C" -maxdepth 1 -type f -name 'values*.y*ml' | sort | while IFS= read -r V; do
    yq -r '.. | select(tag != "!!map" and tag != "!!seq") | [(path | map(select(tag == "!!str")) | join(".")), line] | @tsv' "$V" 2>/dev/null |
      awk -F'\t' -v V="$V" -v sub_="$sub" 'BEGIN { n = split(sub_, s, " "); for (i = 1; i <= n; i++) skip[s[i]] = 1 }
        function read_(q,   k) { for (k in r) if (q == k || index(q, k ".") == 1 || index(k, q ".") == 1) return 1; return 0 }
        NR == FNR { r[$1] = 1; next } { n = split($1, a, "."); if (a[1] in skip || $1 == "") next; q = ""
          for (j = 1; j <= n; j++) { q = q (j > 1 ? "." : "") a[j]; if (!read_(q)) { if (!(q in seen)) print "1a K-HELM-VALUE-UNUSED\t" V ":" $2 "\t." q; seen[q] = 1; break } } }' "$SCAN/.refs" -; done
  while IFS= read -r ref; do [ "$(yq ".$ref" "$C/values.yaml" 2>/dev/null)" = null ] || continue
    rg -n -F ".Values.$ref" "$C/templates" | awk -v r="$ref" '{ g = ($0 ~ /(default|required|hasKey|if|with)[ (]/) ? "guarded" : "bare"; print "1b K-HELM-VALUE-UNDEFINED\t" $0 "\t." r " (" g ")" }'; done < "$SCAN/.refs"
done

# 2. Helm behaviour: lookup (2a); CRDs in crds/ (2b); hook objects with no delete policy (2c); replicas rendered beside an
#    autoscaler in a chart (2d); prod-critical values with no required/default (2e); Helm-4-renamed flags in deploy paths
#    (2f: --atomic, --force; a bare --wait is context for the Helm-major read); server-side apply in deploy paths (2g).
rg -n --pcre2 '\blookup\s+"' -g '**/templates/**' "${X[@]}" .
rg --files . -g '**/crds/*.y*ml' "${X[@]}" -g '!**/charts/*/charts/**'
rg -l --pcre2 '^\s*["\x27]?helm\.sh/hook["\x27]?\s*:' -g '**/templates/**' "${X[@]}" . | xargs -r rg --files-without-match 'helm\.sh/hook-delete-policy'
J | jq -rs "$HR" | awk -F'\t' '$3 == "chart" || $3 == "argo" { print "2d K-HELM-REPLICAS\t" $0 }'
rg -n --pcre2 '\.Values\.(?:image\.(?:tag|repository|digest)|\w*(?:[Hh]ost|[Dd]omain)s?)\b' -g '**/templates/**' "${X[@]}" . | rg -v --pcre2 '\b(required|default|if|with)\b'
awk -F'\t' 'NR > 1 && $2 == "helm" && $9 ~ /(^| )--(atomic|force|wait)( |=|$)/' "$SCAN/inventory-platform.tsv"
rg -n --pcre2 '\bhelm\s[^\n]*--(server-side|force-conflicts)\b|\bkubectl\s+apply\b[^\n]*--server-side\b' -g '*.{sh,bash,mk,yml,yaml,md}' -g 'Makefile' -g 'Justfile' "${X[@]}" .

# 3. Kustomize: commonLabels / includeSelectors (3a); generator hash off (3b); prefix/suffix not rewritten inside strings (3c);
#    patch no-op, by deleting each patch on a COPY and comparing renders (3d); a container with no image, from an SMP patch that
#    named a container the base lacks (3e); spec.replicas on an autoscaled object in a Kustomize env (3f).
rg -n --pcre2 '^commonLabels:|^\s*includeSelectors:\s*true\b' "${KZ[@]}" "${X[@]}" .
rg -n --pcre2 '\bdisableNameSuffixHash:\s*true\b' "${KZ[@]}" "${X[@]}" .
rg -l --pcre2 '^name(Prefix|Suffix):\s*\S' "${KZ[@]}" "${X[@]}" . | while IFS= read -r k; do e="$(dirname "${k#./}" | sed 's#/#__#g; s#[^A-Za-z0-9_.-]#-#g')"
  jq -r --arg p "$(yq -r '.namePrefix // ""' "$k")" --arg s "$(yq -r '.nameSuffix // ""' "$k")" '
    [.[] | select(.kind == "Service") | .metadata.name | ltrimstr($p) | rtrimstr($s)] as $base | .[] | . as $o
    | [paths(type == "string")][] | . as $pp | select(.[0] != "__src" and (.[0] != "metadata" or .[1] == "annotations") and (index("selector") | not)
        and (index("labels") | not) and (.[-1] | tostring | test("[Nn]ame$") | not))
    | ($o | getpath($pp)) as $v | $base[] as $b | select($v | test("(^|//|@)" + $b + "([:/.]|$)"))
    | "3c K-KZ-NAMEPREFIX\t\($o.__src.env)\t\($o.kind)/\($o.metadata.name) \($pp | map(tostring) | join(".")) = \($v)\t\($o.__src.file)"' "$SCAN/render-$e.json"; done
rg --files . "${KZ[@]}" "${X[@]}" | cut -d/ -f2 | sort -u | while IFS= read -r top; do mkdir -p "$SCAN/kzcopy"; cp -R "./$top" "$SCAN/kzcopy/" && chmod -R u+w "$SCAN/kzcopy/$top"; done
rg --files . "${KZ[@]}" "${X[@]}" | while IFS= read -r k; do d="$(dirname "$k")"; full="$(kustomize build "$d" 2>/dev/null | shasum)"
  [ "$(kustomize build "$SCAN/kzcopy/$d" 2>/dev/null | shasum)" = "$full" ] || { printf '3d blocked\t%s\tthe copy does not reproduce the render\n' "$k"; continue; }
  for fld in patches patchesStrategicMerge patchesJson6902; do n="$(yq ".$fld | length" "$k")"; i=0
    while [ "$i" -lt "${n:-0}" ]; do at="$(printf '.%s[%s]' "$fld" "$i")"   # never "$fld[$i]": zsh reads that as a subscript of $fld
      cp "$SCAN/kzcopy/$k" "$SCAN/kzcopy/$k.orig"; yq -i "del($at)" "$SCAN/kzcopy/$k"
      if cmp -s "$SCAN/kzcopy/$k" "$SCAN/kzcopy/$k.orig"; then printf '3d blocked\t%s\t%s was not removed from the copy\n' "$k" "$at"
      elif [ "$(kustomize build "$SCAN/kzcopy/$d" 2>/dev/null | shasum)" = "$full" ]; then
        printf '3d K-KZ-PATCH-NOOP\t%s\t%s %s\n' "$k" "$at" "$(yq -o=json -I=0 "$at | (.target // .path // .)" "$k")"; fi
      mv "$SCAN/kzcopy/$k.orig" "$SCAN/kzcopy/$k"; i=$((i + 1)); done; done; done
J | jq -r 'select(.kind | IN("Deployment","StatefulSet","DaemonSet","Job","CronJob","Rollout","Pod")) | . as $w
  | (if .kind == "CronJob" then .spec.jobTemplate.spec.template.spec elif .kind == "Pod" then .spec else .spec.template.spec end) as $p
  | (($p.containers // []) + ($p.initContainers // []))[] | select(.image == null or .image == "")
  | "3e K-KZ-PHANTOM\t\($w.__src.env)\t\($w.kind)/\($w.metadata.name) container \(.name) has no image\t\($w.__src.file)"'
J | jq -rs "$HR" | awk -F'\t' '$3 == "base" || $3 == "overlay" { print "3f K-KZ-REPLICAS-HPA\t" $0 }'

# 4. GitOps spec, per CD target whose env this repo renders: selfHeal × the HR join with no RespectIgnoreDifferences (4a);
#    prune over stateful kinds with no Prune=false (4b); CRDs / Namespaces / migration Jobs with no wave or hook (4c);
#    ignoreDifferences over all of /spec (4d).
awk -F'\t' 'NR > 1 && $NF != "-"' "$SCAN/cd-targets.tsv" | while IFS="$(printf '\t')" read -r kd nn _s _p _r _v _d sync _f env; do E="$SCAN/render-$env.json"
  case "$sync" in *selfHeal=true*) case "$sync" in *RespectIgnoreDifferences=true*) ;; *) jq -c '.[]' "$E" | jq -rs "$HR" | sed "s#^#4a K-GITOPS-REPLICAS $kd/$nn\t#" ;; esac ;; esac
  case "$sync" in *prune=true*) jq -r '.[] | select(.kind | IN("PersistentVolumeClaim","Namespace","StatefulSet","CustomResourceDefinition"))
    | select((.metadata.annotations // {}) | ((.["argocd.argoproj.io/sync-options"] // "") | test("Prune=false")) or .["kustomize.toolkit.fluxcd.io/prune"] == "disabled" | not)
    | "4b K-GITOPS-PRUNE\t\(.__src.env)\t\(.kind)/\(.metadata.name)\t\(.__src.file)"' "$E" ;; esac
  jq -r --arg t "$kd/$nn" 'select(any(.[]; .kind == "CustomResourceDefinition" or .kind == "Namespace" or (.kind == "Job" and (.metadata.name | test("migrat")))))
    | select(all(.[]; (.metadata.annotations // {}) | has("argocd.argoproj.io/sync-wave") or has("argocd.argoproj.io/hook") or has("helm.sh/hook") | not))
    | "4c K-GITOPS-WAVES\t\($t)\t\(map(select(.kind == "CustomResourceDefinition" or .kind == "Namespace" or .kind == "Job") | "\(.kind)/\(.metadata.name)") | join(","))"' "$E"
done
rg -n --pcre2 '^\s*-?\s*["\x27]?(/spec|\.spec(\.template)?)["\x27]?\s*$' -g '*.y*ml' "${X[@]}" .

# 5. Selector mutation, base → head: render the base ref from `git archive` (reads objects only; no worktree), then compare every
#    workload's spec.selector by kind/namespace/name. A Service whose selector moved with it is the half that applies first.
BASE="${BASE:-$(git merge-base HEAD origin/main 2>/dev/null)}"; mkdir -p "$SCAN/base-tree"
[ -n "$BASE" ] && awk -F'\t' 'NR > 1 { split($6, a, "/"); print a[1] }' "$SCAN/render-ledger.tsv" | sort -u | xargs git archive "$BASE" -- | tar -x -C "$SCAN/base-tree" \
  && bash "$RS" "$SCAN/base-tree" --out="$SCAN/base" --quiet; [ -d "$SCAN/base" ] &&
find "$SCAN/base" -maxdepth 1 -name 'render-*.json' -exec jq -c '.[]' {} + | jq -s 'map({key: "\(.kind)/\(.metadata.namespace // "-")/\(.metadata.name)", value: .spec.selector}) | from_entries' > "$SCAN/base/selectors.json" &&
J | jq -r --slurpfile B "$SCAN/base/selectors.json" '"\(.kind)/\(.metadata.namespace // "-")/\(.metadata.name)" as $k
  | select(.kind | IN("Deployment","StatefulSet","DaemonSet","Service")) | select($B[0] | has($k)) | select($B[0][$k] != .spec.selector)
  | "5 \(if .kind == "Service" then "service-selector-moved" else "K-SELECTOR-MUTATION" end)\t\(.__src.env)\t\($k)\t\($B[0][$k] | tojson) -> \(.spec.selector | tojson)\t\(.__src.file)"'
```

**Measured 2026-10-10.**

- **Research fixture (seeded):** every seeded render-machinery defect hits, and nothing else does:
  - 1a/1b, the `resource:` vs `.Values.resources` typo pair (one per chart);
  - 2a, both `lookup` calls;
  - 2d, `replicas` beside an HPA;
  - 3a, `commonLabels`;
  - 3b, `disableNameSuffixHash`;
  - 3d, the patch whose target `payment` does not exist;
  - 3e, the phantom container `payments`.

  3c is quiet: the prefixed overlay's `LEDGER_URL` names a Service the render does not contain. Seeding `http://pay:80` makes it fire once, in the prefixed env only.
- **Small Argo fixture:** a selfHeal + prune Application over a chart with an HPA gives 4a (1) and 4b (one PVC).
- **Seeded selector change:** a `commonLabels` value change gives 3 env × 2 rows from one root cause (a Deployment, plus the Service that moved with it).
- **Real repo:** 0 candidates in § 1–§ 5. Of 9 overlay patches, none is a no-op. § 5 against a commit from a month earlier compared 26 objects and found 0 changes. 2f returned 4 bare `--wait` lines; they are context, not candidates. The whole script runs in about 3 s.
- **Trap found on the real repo:** a read-only checkout copies read-only. Before `chmod -R u+w`, `yq -i` failed silently and every patch read as a no-op (9 of 9 false positives). The `cmp` guard now turns that into `blocked`. A second trap appeared under zsh: `"del(.$fld[$i])"` subscripts `$fld` itself, so the deletion targeted the wrong path. Under zsh the no-op went unreported and a valid patch was reported as `blocked`. The path is now built with `printf`, and both shells give the same rows.

## The 3,000-hit path

1. **Group** by (class, root-cause file, field path). One base patch, chart helper or values default that reaches 40 envs is **one finding with N sites**, fixed at its root (the base, the helper, the values default).
2. **Rank** prod CD-target envs first, then whatever the runbook applies, then everything else.
3. **Read** the top 20 groups fully, plus 3 samples of each remaining group.
4. **Dismiss** a whole env (an `examples/` overlay, a chart's `ci/` values) only with **one** read that shows nothing deploys it, and name it in the coverage block.
5. **Ledger the rest** as `Live, unreviewed (k groups / n hits — <the continuing command>)`. Never mark them `checked`.

## Scans for

| § | Classes | Default severity |
|---|---|---|
| 0 | A CD-target env that does not render | high (the deploy cannot happen) |
| 1 | K-HELM-VALUE-UNUSED, K-HELM-VALUE-UNDEFINED | medium; high when the dropped key is resources, replicas, probes or security context on a prod env |
| 2 | K-HELM-LOOKUP, K-HELM-CRDS, K-HELM-HOOK-DELETE, K-HELM-REPLICAS, K-HELM-REQUIRED, K-HELM-FLAGS, K-HELM-SSA | REPLICAS high; LOOKUP, CRDS, HOOK-DELETE, FLAGS, SSA medium; REQUIRED low |
| 3 | K-KZ-COMMONLABELS, K-KZ-HASH, K-KZ-NAMEPREFIX, K-KZ-PATCH-NOOP, K-KZ-PHANTOM, K-KZ-REPLICAS-HPA | PATCH-NOOP and PHANTOM high; the rest medium |
| 4 | K-GITOPS-REPLICAS, K-GITOPS-PRUNE, K-GITOPS-WAVES, K-GITOPS-IGNORE-WIDE | REPLICAS and PRUNE high; WAVES and IGNORE-WIDE medium |
| 5 | K-SELECTOR-MUTATION | high |

Severity drops one step on a non-prod env that is deployed (precedent 7), and to Hardening on one nothing deploys. No Kubernetes class has a CWE; the class ids are the pack's.

Oracles, by name and number, in [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Infrastructure: *Render matrix* V262 (§ 0, § 1, Helm values xref merged in) · *Patch no-op/phantom* V263 (§ 3) · *SSA / upgrade twice* V264 (§ 2) · *Selector mutation* V265 (§ 5) · *GitOps spec* V266 (§ 4).

### 0. A CD-target env that does not render

```
BAD:   charts/app/Chart.yaml  dependencies: [{name: redis, …}]  and no charts/ dir → `helm template` exits 1; the deploy job fails
GOOD:  the dependency vendored or built in CI before the render; the CD target renders with the project's own flags
```
**Read the code:** Is the failing root the one the CD target applies? Is the failure the renderer's (a missing chart dependency, `helmCharts` without `--enable-helm`, an unset `required`) or this run's (a remote base not fetched, a tool missing)? Only the first is a finding. **Finding when** the env a CD target deploys exits non-zero with the project's own renderer flags. **Proof:** the `render.err` line plus the CD target's file:line. **Oracle:** *Render matrix*.

### 1. Helm values that never reach a template

```
BAD:   values-prod.yaml   resource: {requests: {cpu: 500m}}          # typo; deployment.yaml reads .Values.resources
       templates/x.yaml   {{- with .Values.resources }}             # renders nothing; the pod runs BestEffort
GOOD:  values.schema.json with "additionalProperties": false  → `helm lint` and `helm template` fail on the typo
```
**Read the code:** Is the 1a key consumed by a subchart (a key named after a dependency or alias), by `tpl`, by `index .Values "x"`, or by a library chart? Is a 1b `guarded` ref the other half of a 1a key with a near-identical name? Which values file does the prod CD target pass (Argo `valueFiles`, `-f` in the inventory, CI `--set`)? **Finding when** a key set in a deployed values file reaches no template, or a template reads a key no deployed values file sets, so the field silently drops out of the prod render. **Proof:** the render diff with the key corrected shows the field appear (*Render matrix*). `helm lint --strict` is not proof either way: it passed the typo on the research fixture (2026-10-10). **Fix:** correct the key, and add a `values.schema.json` with `additionalProperties: false` so the next typo fails the lint.

### 2. Helm behaviour that differs from the render

```
BAD:   {{- $s := lookup "v1" "Secret" .Release.Namespace "db" }}     # empty under helm template, which is how Argo CD renders
       replicas: {{ .Values.replicaCount }}                          # rendered while autoscaling.enabled — Helm 4 SSA upgrade fails
       helm upgrade --install app ./chart --atomic                  # Helm 4: the flag is --rollback-on-failure
GOOD:  {{- if not .Values.autoscaling.enabled }}replicas: {{ .Values.replicaCount }}{{- end }}
       CRDs shipped as templates or a separate chart, upgraded on purpose; hook Jobs with before-hook-creation
```
**Read the code:**

- *2a.* Who renders this chart: `helm template`, an Argo CD sync, or `helm install`? Does the `lookup` branch have a deterministic fallback?
- *2b.* Did the chart's CRD schema change since the version the cluster has? Files in `crds/` are installed once and never upgraded, under Helm 3 and Helm 4 alike.
- *2c.* Does the hook object survive a second `helm upgrade`?
- *2d.* Is `replicas` behind the autoscaling guard? A value that is never read (a 1a `.autoscaling` row) means there is no guard.
- *2f, 2g.* Which Helm major runs the inventory line?

**Finding when:**

- the prod render (or the deployed Helm major) takes the branch that breaks;
- a hook or CRD upgrade would fail or leave stale state;
- or a renamed flag sits on a deploy path run by Helm 4.

**Proof:**

- *Render matrix* (V262): `helm template` at the target `--kube-version` with the prod values.
- *SSA / upgrade twice* (V264): install, then upgrade twice on a scratch kind cluster through an explicit `--kubeconfig`. Helm 4 fails with a conflict against `kube-controller-manager` on `.spec.replicas` (measured on kind, node v1.37.0, Helm 4.2.4, 2026-10-10).
- *GitOps spec* (V266) for a lookup branch Argo never sees.

### 3. Kustomize patches and transformers

```
BAD:   patches: [{target: {kind: Deployment, name: payment}, patch: …}]   # the base calls it `pay`: applies nothing, exit 0
       patches: [{path: res.yaml}]   # res.yaml names container `payments`; the base has `pay` → a second container, no image
       commonLabels: {team: payments}                                       # rewrites selectors; a later edit cannot apply
       configMapGenerator: [{name: cfg, options: {disableNameSuffixHash: true}}]  # config edits never roll the pods
GOOD:  a patch target that exists in the base render · labels: [{pairs: {team: payments}, includeSelectors: false}]
```
**Read the code:**

- *3d.* Was the no-op patch meant for a resource that the base later renamed or removed?
- *3e.* Does the phantom container's name differ from a base container by a typo?
- *3a.* Has the label already been applied to a live cluster? If so, the fix itself is a selector change, and § 5 applies.
- *3b.* Which workload consumes the unhashed ConfigMap, and does Reloader or a checksum roll it (precedent 13)? The rollout gap itself is graded by `k8s-wiring-scan` § 1.
- *3c.* Is the unprefixed name in the string an in-cluster Service the prefix renamed, or an external host?
- *3f.* Is this the trial-size patch with a documented exit condition (precedent 47)?

**Finding when:**

- *3d.* The render with and without the patch is identical.
- *3e.* A deployed env renders a container with no image.
- *3a.* `commonLabels` or `includeSelectors: true` sits in a base that has been deployed.
- *3b.* A generated ConfigMap with the hash off is consumed by a workload with no other rollout trigger.
- *3c.* A string names a renamed Service.
- *3f.* An autoscaled object carries `replicas` in a deployed env.

**Proof:**

- *Patch no-op/phantom* (V263): the 3d render-hash comparison, and a server dry-run on scratch kind rejecting the image-less container ("image: Required value").
- *Render matrix* (V262) for 3a, 3b and 3c.

### 4. GitOps spec

```
BAD:   syncPolicy: {automated: {selfHeal: true}}   over a Deployment that renders replicas and has an HPA     # reset every sync
       syncPolicy: {automated: {prune: true}}      over a PVC with no Prune=false                              # a path move deletes data
       ignoreDifferences: [{kind: Deployment, jsonPointers: [/spec]}]                                          # drift is invisible
GOOD:  omit replicas from the manifest · or ignoreDifferences /spec/replicas WITH syncOptions: [RespectIgnoreDifferences=true]
       argocd.argoproj.io/sync-options: Prune=false on PVCs and Namespaces · sync-wave on CRDs before their CRs
```
**Read the code:**

- *4a.* Does the HR join's object live in the env this Application deploys? `ignoreDifferences` without `RespectIgnoreDifferences=true` hides the diff and still resets the count (precedent 14).
- *4b.* Is a move of resources between Applications planned? Does `prune` reach a PVC or Namespace holding data?
- *4c.* Does the first sync on an empty cluster apply a CR before its CRD, or a Deployment before its migration Job?
- *4d.* Does the pointer cover all of `/spec` or `.spec.template`?

ApplicationSet generators are not expanded here. Their rows are Unconfirmed, listing the generator's file:line.

**Finding when** the deployed Application's spec makes the controller undo, delete or misorder something the render needs. **Proof:** *GitOps spec* (V266) — `argocd app diff` / `flux diff kustomization` against a scratch kind cluster with the controller installed, else Unconfirmed with that command written out. **Fix:** in the Application or Kustomization spec, or by removing `replicas` from the manifest.

### 5. Selector mutation between base and head

```
BAD:   base: selector {app: pay, team: payments}  →  head: {app: pay, team: billing}      # commonLabels edit, or a label helper
GOOD:  new label on the template only (includeSelectors: false); a selector change ships as a new object name + migration
```
**Read the code:** Is the object already live? A first deploy is not a mutation. Does the Service's selector move in the same diff? It applies before the workload fails, which leaves an EndpointSlice with no endpoints. Is the change from `commonLabels`, a chart's `selectorLabels` helper or a direct edit? **Finding when** a deployed workload's `spec.selector` differs between the base render and the head render. **Proof:** *Selector mutation* (V265) — apply the base render, then the head render, to a scratch kind cluster (explicit `--kubeconfig`). The second apply fails with `spec.selector … field is immutable`, while the Service has already moved (measured on kind, node v1.37.0, 2026-10-10). **Fix:** revert the selector and put the label on the template only. If the selector change is wanted, ship it as a recreate plan: a new object name plus a migration. Either way, stop before merge (`/infra-fix`, when present, asks the user first).

## Tool lane

| Tool (measured version) | Command, on `$SCAN` copies | Reads | Trap |
|---|---|---|---|
| kustomize 5.8.3 / `kubectl kustomize` | `render.sh` | every root | deprecations go to stderr only; a non-matching patch target exits 0 |
| helm 4.2.4 | `render.sh` (`helm template`, scratch homes, empty `KUBECONFIG`) | every chart × values file | `lookup` renders empty; `--dry-run=server` would contact the ambient context, so it is never used |
| `helm lint --strict` | `helm lint --strict <chart> -f <values>` | the chart | passes an unknown values key unless `values.schema.json` forbids it |
| kubeconform 0.8.0 | `kubeconform -strict -summary -output json -kubernetes-version <minor> render-<env>.yaml canary.yaml` | objects + 2 | `-ignore-missing-schemas` turns removed APIs into `skipped` with exit 0; YAML 1.1 decoding (the `n` canary comes back as `false`) |
| pluto 5.24.4 | `pluto detect-files -d "$SCAN" -o json --target-versions k8s=v<minor>` | files in `$SCAN` | its default target is old; always pass `--target-versions` |
| kind (scratch) | the K-tier oracles, with `--kubeconfig "$SCAN/kind.kubeconfig"` | — | never the user's context. No scratch cluster means the K-tier rows are Unconfirmed, with the command written out |

Missing kustomize and kubectl → every Kustomize root reads `not-installed` (`brew install kustomize`). Missing yq or jq → no JSON is written, so § 1 and § 3c–§ 5 are `blocked (yq/jq not installed)`.

**Stacks real repos use:**

| Stack | How it is rendered |
|---|---|
| Plain manifests | raw envs |
| Kustomize | standalone or kubectl-embedded |
| Helm 3 or 4 charts in the repo | rendered per values file |
| Charts installed from a remote repo | inventory rows only; the chart defaults are read at the pinned version with `helm show values <repo>/<chart> --version <v>` (network, no cluster) |
| Argo CD | an Application's chart or Kustomize path is rendered; ApplicationSet generators are not expanded |
| Flux | the path is mapped to its env; `postBuild` substitution is not applied, so the row is tagged |
| Helmfile, Skaffold, Tilt, cdk8s, Tanka/Jsonnet, Timoni | not run by `render.sh`. Run the project's own render command on a copy and feed its output to the same ledger, or the row reads `blocked (no renderer for <tool>)` |

## Output

```
k8s-render-scan — kustomize v5.8.3 · helm v4.2.4 (CI: azure/setup-helm v4.3.0) · target k8s 1.36 (runbook) · CD: Argo CD
Render: 5 envs · 61 objects · 0 failed · stderr 1 (commonLabels deprecated) · canaries read by kubeconform (27/27)

Findings: 2 · Unconfirmed: 1

1. chart/app/values-prod.yaml:1 + chart/app/templates/deploy.yaml:5          [report-with-fix]   high
   §4 K-GITOPS-REPLICAS — env argo__web renders Deployment/web replicas 3 while HPA web (min 2, max 6) owns the count;
   Application argocd/web (apps/app.yaml:7) runs selfHeal with no RespectIgnoreDifferences: every scale-up is reverted.
   Proof: Render matrix (V262) — argo__web render shows both objects; values.yaml:2 `autoscaling` is read by no template (1a).
   Fix: wrap replicas in {{- if not .Values.autoscaling.enabled }} (templates/deploy.yaml:5); siblings: 2 chart envs.

2. k8s/overlays/prod/kustomization.yaml:4                                        [report-with-fix]   high
   §3 K-KZ-PATCH-NOOP — patches[0] targets Deployment `payment`; the base renders `pay`. The 6-replica patch applies nothing.
   Proof: Patch no-op (V263) — render hash identical with and without patches[0]. Fix: target name `pay`.

3. chart/app/templates/deploy.yaml:1                                             [report-flagged]    medium
   §2 K-HELM-LOOKUP — the lookup branch is empty under Argo's helm template. Unproven link: whether the prod path relies on
   the existing Secret. Settling run: helm template vs helm install --dry-run=server on scratch kind (explicit --kubeconfig).

Verification: 9 candidates → 2 findings · 1 unconfirmed · 3 hardening · 0 pre-existing · 3 refuted

Class coverage:
  §0  render contract       checked (5 envs, 61 objects; 0 failed; kubeconform read 61 + 2)
  §1  Helm values           checked (2 hits → 1 group, folded into finding 1)
  §2  Helm behaviour        checked (4 hits → 1 unconfirmed; 2f: 0 renamed flags, 3 bare --wait read against Helm 4)
  §3  Kustomize             checked (9 patches → 1 no-op; 0 phantom; commonLabels 1 → hardening, never applied)
  §4  GitOps spec           checked (1 Application → 1 finding; 4b: 0) · ApplicationSet generators N/A (none)
  §5  selector mutation     checked (base origin/main, 61 objects compared → 0)
Tool ledger: kustomize 5.8.3 RAN · helm 4.2.4 RAN · kubeconform 0.8.0 RAN (63/63) · pluto 5.24.4 RAN · kind NOT-RUN (no consent)
```

`[report-with-fix]` means the deployed env, the oracle and the source fix are all in hand. `[report-flagged]` means one link is unproven (a Helm major not found in files, no scratch cluster for a K-tier oracle, an ApplicationSet not expanded), and the row names that link and the run that settles it. The coverage block lists **§ 0–§ 5 on every run**:

- `checked (k hits → m)` only when the step ran;
- `N/A (<reason>)` when the repo has no such surface (no chart, no CD controller);
- `blocked (<reason>)` when a renderer or tool was missing, or a copy did not reproduce the render;
- `Live, unreviewed (…)` for a partial review.

## False positives / precedents

The verifier reads [`precedents/infrastructure.md`](../../../../snippets/precedents/infrastructure.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most:

- **An HPA-managed object with `replicas` omitted is correct** (precedent 6). The HR join already skips it. Tools that read it as one replica are wrong.
- **Dev and preview overlays** are not prod findings (precedent 7), unless that overlay is the CD target. A trial-size patch with a written exit condition is `accepted-risk` Hardening until the condition is met (precedent 47).
- **A removed apiVersion behind a `Capabilities.APIVersions.Has` / `semverCompare` guard** is not a finding at the target minor (precedent 12). Render at the target `--kube-version` before reporting.
- **`disableNameSuffixHash` with Reloader, or `immutable` plus versioned names,** is controlled (precedent 13), provided Reloader is installed in the target cluster (read the inventory).
- **Argo `ignoreDifferences /spec/replicas` plus `RespectIgnoreDifferences=true`** clears K-GITOPS-REPLICAS (precedent 14). `ignoreDifferences` alone does not.
- **A KEDA `ScaledObject` is an autoscaler** for the HR join (precedent 42). Its floor belongs to `k8s-availability-scan`.
- **A chart installed by a runbook is in scope** (precedent 45). Its values file is read even though no chart is in the repo.
- **Scanner silence is not a pass.** A tool that read fewer objects than the render holds is NOT-RUN for the difference.

## When to run

- First, in every `/infra-audit` that touches Kubernetes: the other three Kubernetes detectors read § 0.
- On a diff touching `kustomization.yaml`, a patch, `Chart.yaml`, `values*.yaml`, `templates/`, an Argo or Flux object, or a runbook or CI line that runs `helm` or `kubectl apply`. Diff the **render** for § 5, not the source.
- Before a Helm major upgrade (2f, 2g), and before a label or selector refactor (§ 5).

## Halt conditions

- Do not report a high row as confirmed until a verifier other than the run that found it has passed it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md). Under `/infra-audit` that is `@k8s-reviewer` in refute mode. Run standalone, apply the protocol as a separate second pass, tag those rows `self-verified`, and list the rest as Unconfirmed, Hardening, Pre-existing or Refuted. Never drop them.
- **Never touch a real cluster.** `render.sh` points `KUBECONFIG` at an empty scratch file. Every K-tier oracle runs against a scratch kind cluster with an explicit `--kubeconfig "$SCAN/kind.kubeconfig"`, and is refused if `KUBECONFIG` is unset or resolves to `~/.kube/config`. Never run `helm install/upgrade`, `kubectl apply` (`--dry-run=server` included) or `argocd`/`flux` against the ambient context.
- **Never write in the repo.** No `helm dependency update`, `kustomize edit`, or `yq -i` on tracked files; every mutation is on a `$SCAN` copy. A mutation that left the copy unchanged is `blocked`, never a no-op finding.
- Do not fetch a remote base or chart repo without the user's consent in this session.
- Halt on any finding without the source `<file:line>`, the env, the CD-target answer and the fix in the source.
- Do not print "render clean" without the ledger and the canary result, and do not report an env as checked when its render failed.
- Do not grade a class another skill or pack owns (§ Boundaries); cross-link it.

## Related

- `/infra-audit` (this pack) dispatches this skill first, and `/infra-fix` closes its findings with the failing-first oracle and a full re-render of every env.
- `@k8s-reviewer` judges the rows.
- `k8s-availability-scan`, `k8s-wiring-scan` and `k8s-api-version-scan` (this pack) read § 0's render.
- `iac-state-scan` § 3 reads `inventory-platform.tsv` for PIN-HELM.
- `edge-reliability-scan` reads `embedded/`.
- `k8s-audit` (this pack) owns the live cluster, behind its own context halt.
- `/k8s-generate` should run § 0 and § 3 on its own output before it claims PRODUCTION-GRADE.
- Security `iac-scan` (§ 3, grep 1e/1f) reads the same render when the security pack is installed.

Files in this skill

  • SKILL.md43.8 KB
  • scripts/render.sh43.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.