Skip to content
Skillv1.0.0

audit-kubernetes

Read-only scored audit of a Kubernetes cluster's security and operational posture — Pod Security Admission enforcement, RBAC over-permissioning, network-policy coverage, workload resource limits, and

by Scoutflo(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from Scoutflo/ai-readiness (skills/audit-kubernetes/SKILL.md). Install upstream with npx skills add Scoutflo/ai-readiness --skill audit-kubernetes. Copyright stays with the author.

audit-kubernetes

Scored, read-only audit of a Kubernetes cluster's security and reliability posture: whether pods run under an enforced security standard, whether any workload identity holds far more RBAC than it needs, whether the pod network is segmented, whether workloads declare resource limits, and whether critical workloads can survive a node drain. It answers one question: if this cluster is attacked or loses a node tonight, does its own configuration contain the blast radius, or amplify it?

This audit reads cluster objects (via kubectl get/auth can-i). Whether the in-cluster observability stack is healthy is audit-lgtm/audit-grafana; the cloud provider's own control-plane monitoring is audit-aws/audit-gcp. This audit stops at the cluster's own security and workload configuration.

Every command is read-only: kubectl get, kubectl auth can-i, kubectl api-resources, kubectl version. The live-runtime snapshot (Phase 8) additionally uses the read-only top verb and get events, and only through the guarded le_kubectl wrapper in the shared live-evidence library (allowlisted read verbs, mechanically enforced by ci/liveness-readonly-check.sh). No apply, create, edit, patch, delete, label, annotate, scale, cordon, or exec — the full forbidden list is in references/kubernetes-checks.md section 9. setup-kubernetes performs the confirm-then-verify fixes; this audit only names them.

Scope boundaries, stated so a green score never overpromises:

  • Multiple clusters, one run: kubernetes may be a single block (one context) or a list of labeled targets, each with its own context. The audit iterates every target — enumerate them with sh "${CLAUDE_PLUGIN_ROOT}/report-standard/toolkit-targets.sh" <cfg> kubernetes labels and run the full sequence below once per target with SCOUTFLO_TARGET=<label> set. Output nests per target: a labeled list writes kubernetes/<label>/<date>/, and a single block keeps writing kubernetes/<context>/<date>/ (byte-identical to today — the single-block label derives from its own context, so nothing about a one-cluster config changes). Every kubectl call passes --context "<the target's context>" explicitly; the current/active kube-context is never used for targeting.

Run this standalone, from /scoutflo:audit-all, or on a schedule via /scoutflo:schedule-audits.

Outputs, per the report standard:

  • ./scoutflo-audits/kubernetes/<label>/<YYYY-MM-DD>/findings.json per the findings schema, scored posture finding IDs K8S-NNN, plus K8SRT-NNN for the parallel non-scored live-runtime snapshot section (Phase 8; area: live-runtime, always severity info and points_recoverable: 0, never in score.categories or score.excluded). The <label> directory segment is the target's label for a labeled list, or the cluster's own context for a single block — either way one machine's kubeconfig routinely reaches several clusters, so each gets its own directory and history. .target is the per-target slug kubernetes/<label> (equal to kubernetes/<context> for a single block).
  • ./scoutflo-audits/kubernetes/<label>/<YYYY-MM-DD>/report.md per the report template, including the ## Inventory section (the render-report-viz.sh inventory output) and the ## Findings by purpose section (the render-report-viz.sh lanes output, splitting findings by report_lanes into the general-audit and AI-SRE-readiness lanes)
  • ./scoutflo-audits/kubernetes/<label>/<YYYY-MM-DD>/inventory.json per the inventory schema (scoutflo-inventory/v1): the complete Phase-1 catalog — one item per namespace, workload (Deployment/StatefulSet/DaemonSet), NetworkPolicy, RBAC binding, and PodDisruptionBudget — each with kind, covers, enabled, severity, and routes_to for alerting objects. Built from the raw pull, never invented; redacted at capture, never a secret value.
  • One appended line in ./scoutflo-audits/kubernetes/<label>/history.jsonl
  • One Slack brief, when slack.webhook_env is configured

Doctor gate

Integration toolkit.yaml keys Secret Minimum scope Tier
Kubernetes kubernetes.context none (identity comes from the kubeconfig context) built-in view ClusterRole (get/list on pods, deployments, services, networkpolicies, roles, rolebindings, clusterroles, clusterrolebindings, poddisruptionbudgets, namespaces) read-only
Slack (optional) slack.webhook_env webhook variable post to one channel n/a
set -eu
CFG="${SCOUTFLO_CONFIG:-}"
[ -n "$CFG" ] || for _c in "./.scoutflo/toolkit.yaml" "$(cat "$HOME/.scoutflo/active-config" 2>/dev/null || true)" "$HOME/.scoutflo/toolkit.yaml"; do [ -f "$_c" ] && { CFG="$_c"; break; }; done
[ -n "$CFG" ] || CFG="$HOME/.scoutflo/toolkit.yaml"
if [ ! -f "$CFG" ]; then
  # Multi-environment setup: a customer running prod+nonprod often has no default
  # toolkit.yaml but named variants (toolkit-prod.yaml, toolkit-nonprod.yaml). List
  # them so the choice is directed, not a dead stall — but NEVER auto-pick an
  # environment (auditing the wrong one is worse than asking).
  ENVCFGS=$(for d in "./.scoutflo" "$HOME/.scoutflo"; do ls "$d"/toolkit-*.yaml 2>/dev/null; done)
  if [ -n "$ENVCFGS" ]; then
    echo "no default config at $CFG, but found environment-specific configs:"
    printf '%s\n' "$ENVCFGS" | sed 's/^/  - /'
    echo "re-run with SCOUTFLO_CONFIG=<one of the above> for the environment you want (never auto-picked), or run /scoutflo:connect to create a default"
  else
    echo "missing $CFG; run /scoutflo:connect"
  fi
  exit 1
fi
# Load the home-anchored secret store so a token added to ~/.scoutflo/env (by connect,
# even mid-session) is seen here without re-exporting or opening a new terminal. It only
# sets *_env variables; no secret value is printed. A profile that already sources it makes
# this a no-op. This mirrors what /scoutflo:doctor does, so doctor and this audit agree.
SCOUTFLO_ENV="${SCOUTFLO_ENV_FILE:-}"; [ -n "$SCOUTFLO_ENV" ] || { if [ -f "./.scoutflo/env" ]; then SCOUTFLO_ENV="./.scoutflo/env"; else SCOUTFLO_ENV="$HOME/.scoutflo/env"; fi; }
[ -f "$SCOUTFLO_ENV" ] && . "$SCOUTFLO_ENV" || true
for bin in kubectl jq; do
  command -v "$bin" >/dev/null || { echo "missing binary: $bin"; exit 1; }
done

# Resolve the CURRENT kubernetes target from toolkit.yaml — a single block (one `context`), or the
# SCOUTFLO_TARGET-selected item of a labeled list (the shared enumerator handles both; no yq required).
# kubernetes.context names the exact context to audit; it is passed explicitly on EVERY kubectl call
# (--context "$KUBE_CONTEXT"), so the current/active context is never used for targeting and a stray
# `kubectl config use-context` elsewhere cannot silently redirect this audit. Output nests per target:
# a single block writes kubernetes/<context>/<date>/ (byte-identical to today, label deriving from the
# context); a labeled list writes kubernetes/<label>/<date>/.
TT="${CLAUDE_PLUGIN_ROOT:-.}/report-standard/toolkit-targets.sh"
K8S_KIND=$(sh "$TT" "$CFG" kubernetes kind); K8S_N=$(sh "$TT" "$CFG" kubernetes count)
[ "${K8S_N:-0}" -ge 1 ] || { echo "no kubernetes target configured in $CFG; run /scoutflo:connect"; exit 1; }
K8S_IDX=0; if [ -n "${SCOUTFLO_TARGET:-}" ]; then _i=0; while [ "$_i" -lt "$K8S_N" ]; do [ "$(sh "$TT" "$CFG" kubernetes label "$_i")" = "$SCOUTFLO_TARGET" ] && { K8S_IDX=$_i; break; }; _i=$((_i+1)); done; fi
K8S_LABEL=$(sh "$TT" "$CFG" kubernetes label "$K8S_IDX"); KUBE_CONTEXT=$(sh "$TT" "$CFG" kubernetes get "$K8S_IDX" context)
[ -n "$KUBE_CONTEXT" ] || { echo "kubernetes target '${K8S_LABEL:-?}' has no context in $CFG; run /scoutflo:connect"; exit 1; }
if [ "$K8S_KIND" = seq ]; then K8S_SEG="kubernetes/${K8S_LABEL}"; else K8S_SEG="kubernetes/${KUBE_CONTEXT}"; fi
echo "kubernetes target: ${K8S_LABEL} (context ${KUBE_CONTEXT}) -> ${K8S_SEG}/"
kubectl config get-contexts -o name | grep -qx "$KUBE_CONTEXT" \
  || { echo "context '$KUBE_CONTEXT' not in kubeconfig; run kubectl config get-contexts, fix kubernetes.context"; exit 1; }
# Entra-integrated AKS contexts authenticate through a kubelogin exec plugin. If
# this context needs it and it is missing, say so plainly now instead of failing
# below with a cryptic exec error. A cert/local-account AKS context (no
# exec.command) or an EKS/GKE context (whose exec.command is aws / gke-gcloud-auth-plugin,
# not kubelogin) falls through and skips this. This mirrors the /scoutflo:doctor probe.
EXEC_CMD="$(kubectl config view --minify --context "$KUBE_CONTEXT" -o jsonpath='{.users[*].user.exec.command}' 2>/dev/null || true)"
case "$EXEC_CMD" in
  *kubelogin*) command -v kubelogin >/dev/null \
    || { echo "context '$KUBE_CONTEXT' uses the kubelogin exec plugin (Entra-integrated AKS) but kubelogin is not installed; run: az aks install-cli"; exit 1; } ;;
esac
# Capture stderr (send stdout to /dev/null) so a private/unreachable API server is told
# apart from an RBAC or credential gap — `auth can-i` writes the transport error to stderr,
# and a `>/dev/null 2>&1` probe would throw it away and misfile the failure below.
K_ERR="$(kubectl --context "$KUBE_CONTEXT" auth can-i get pods -A 2>&1 1>/dev/null)" && K_RC=0 || K_RC=$?
if [ "$K_RC" -ne 0 ]; then
  # A private or unreachable API server (AKS private cluster, no VNet route, caller IP not
  # in the API-server authorized ranges) is a NETWORK problem, not RBAC or an expired
  # credential — match its transport signatures FIRST, before the exec-plugin/RBAC branches
  # below misattribute it to "run az login" or "bind the view ClusterRole".
  case "$K_ERR" in
    *localhost:*|*127.0.0.1:*)
      echo "context '$KUBE_CONTEXT': its API server is a local tunnel (localhost) that is not answering — your just-in-time / tunnelled access session (Cloudanix cdx, Teleport, a bastion 'ssh -L' tunnel, or 'kubectl port-forward') has expired or was never opened. Re-establish it in another terminal and point KUBECONFIG at the session's kubeconfig by adding one line to the store this audit already reads: echo 'export KUBECONFIG=\"<the session's kubeconfig path>\"' >> ~/.scoutflo/env — then retry. Scoutflo holds no standing cluster credential by design, so it can only audit while your session is live (see 'Accessing private or JIT-tunnelled clusters' below). This is NOT an RBAC, credential, or authorized-IP-range problem."; exit 1 ;;
    *"no such host"*|*"dial tcp"*|*"i/o timeout"*|*"connection refused"*|*"Unable to connect"*|*"TLS handshake timeout"*|*privatelink*)
      echo "context '$KUBE_CONTEXT': the AKS/API server is private or unreachable from here — run from inside the VNet (jumpbox/VPN/bastion), add your IP to the cluster API-server authorized IP ranges, or open a tunnel/JIT session and point KUBECONFIG at its kubeconfig (see 'Accessing private or JIT-tunnelled clusters' below). \`az aks command invoke\` only carries a SINGLE tiny read — its 512 KB output cap truncates this audit's inventory pull (measured: a 39-pod cluster's pods-only JSON is already 524 KB), so it is not a substitute. This is NOT an RBAC or credential problem. The cluster monitoring posture is still auditable via /scoutflo:audit-azure over ARM (Container Insights, managed Prometheus, diagnostic settings) with no cluster network access."; exit 1 ;;
  esac
  # Not a network signature: a failed can-i on an exec-plugin context is usually an EXPIRED
  # CREDENTIAL, not a missing RBAC binding — so name the reauth path per exec plugin BEFORE
  # the generic RBAC message (mirrors the kubelogin branch above). Only a non-exec context
  # falls through to the RBAC fallback.
  case "$EXEC_CMD" in
    *gke-gcloud-auth-plugin*) echo "context '$KUBE_CONTEXT' (GKE) could not authenticate — the gke-gcloud-auth-plugin credential is likely expired, not an RBAC gap; run: gcloud auth login (then gcloud container clusters get-credentials <cluster> to refresh the token), then retry"; exit 1 ;;
    *aws*)                    echo "context '$KUBE_CONTEXT' (EKS) could not authenticate — the aws exec-plugin credential is likely expired, not an RBAC gap; run: aws sso login (or otherwise refresh your AWS credentials), then retry"; exit 1 ;;
    *kubelogin*)              echo "context '$KUBE_CONTEXT' (Entra AKS) could not authenticate — the kubelogin credential is likely expired, not an RBAC gap; run: az login to refresh it (or az aks install-cli if kubelogin is missing), then retry"; exit 1 ;;
    *)                        echo "context reaches no cluster or lacks read RBAC; bind the view ClusterRole (connect references/providers.md)"; exit 1 ;;
  esac
fi
echo "doctor gate: pass"

Never proceed past a failed doctor check and never downgrade one into a finding. /scoutflo:doctor runs the same auth can-i probe standalone.

Accessing private or JIT-tunnelled clusters

This audit talks to the cluster only through kubectl, which reads whatever kubeconfig $KUBECONFIG names. That makes a private cluster reachable through any just-in-time or tunnelled access path — Cloudanix cdx, Teleport, HashiCorp Boundary, a bastion ssh -L tunnel, kubectl port-forward, or a VPN — with no plugin-specific integration and no stored credential. The plugin rides whatever session is live and holds nothing:

  1. In one terminal, open your access session (for example cdx k8s connect …) and leave it running — the tunnel and the kubeconfig it writes stay alive only while that session is open.
  2. That tool writes a kubeconfig to a temporary path and usually sets KUBECONFIG to it. Because the plugin runs in its own shell, tell it where that kubeconfig is by adding one line to the home-anchored store this audit already sources (the same place credentials live): echo 'export KUBECONFIG="<the session'\''s kubeconfig path>"' >> ~/.scoutflo/env — find the path with echo "$KUBECONFIG" inside the session.
  3. Set kubernetes.context in ~/.scoutflo/toolkit.yaml to the context inside that kubeconfig (KUBECONFIG=<path> kubectl config get-contexts -o name). The context name is stable across sessions; the temporary file path and the tunnel port are not, so never hard-code the path in the config — always resolve it from $KUBECONFIG.
  4. Run the audit. Every kubectl --context … call rides the live tunnel. When the session expires the audit simply loses access and says so — Scoutflo retains no token, kubeconfig, or cluster credential.

An in-cluster backend with no external ingress (ClickHouse/HyperDX for audit-clickstack, or a store for audit-lgtm / audit-prometheus / audit-signoz) is reached the same way: kubectl port-forward it through this kubeconfig and point that audit's URL at http://127.0.0.1:<local-port>.

Live-safety gate

Print what you are pointed at and confirm it before the first real check:

set -eu
CFG="${SCOUTFLO_CONFIG:-}"; [ -n "$CFG" ] || for _c in "./.scoutflo/toolkit.yaml" "$(cat "$HOME/.scoutflo/active-config" 2>/dev/null || true)" "$HOME/.scoutflo/toolkit.yaml"; do [ -f "$_c" ] && { CFG="$_c"; break; }; done; [ -n "$CFG" ] || CFG="$HOME/.scoutflo/toolkit.yaml"
[ -f "$CFG" ] || { echo "missing $CFG; run /scoutflo:connect"; exit 1; }
# Resolve the CURRENT kubernetes target (single block or SCOUTFLO_TARGET-selected list item); never hand-typed.
TT="${CLAUDE_PLUGIN_ROOT:-.}/report-standard/toolkit-targets.sh"
K8S_N=$(sh "$TT" "$CFG" kubernetes count)
K8S_IDX=0; if [ -n "${SCOUTFLO_TARGET:-}" ]; then _i=0; while [ "$_i" -lt "$K8S_N" ]; do [ "$(sh "$TT" "$CFG" kubernetes label "$_i")" = "$SCOUTFLO_TARGET" ] && { K8S_IDX=$_i; break; }; _i=$((_i+1)); done; fi
K8S_LABEL=$(sh "$TT" "$CFG" kubernetes label "$K8S_IDX"); KUBE_CONTEXT=$(sh "$TT" "$CFG" kubernetes get "$K8S_IDX" context)
[ -n "$KUBE_CONTEXT" ] || { echo "kubernetes target '${K8S_LABEL:-?}' has no context in $CFG; run /scoutflo:connect"; exit 1; }
SERVER="$(kubectl --context "$KUBE_CONTEXT" config view --minify -o jsonpath='{.clusters[0].cluster.server}' 2>/dev/null)"
VER="$(kubectl --context "$KUBE_CONTEXT" version -o json 2>/dev/null | jq -r '.serverVersion.gitVersion // "unknown"')"
NS_COUNT="$(kubectl --context "$KUBE_CONTEXT" get ns -o json | jq '.items | length')"
echo "target=${K8S_LABEL} context=${KUBE_CONTEXT} server=${SERVER} k8s=${VER} namespaces=${NS_COUNT}"
echo "live-safety gate: pass — confirm this is the cluster you intend to audit"

The context selects the cluster; there is no ambient default in this audit. The server URL and version are the human confirmation, and the version drives the Pod Security check below (PSP vs PSA).

Ground rules

  • Configuration is metadata; observed behavior is proof. A namespace with a pod-security.kubernetes.io/enforce label is configured; a workload actually admitted under that standard is validated-live.
  • PodSecurityPolicy was removed in Kubernetes 1.25. Detect the server version first. On 1.25+ (every currently-supported cluster) the pod-security check evaluates Pod Security Admission — the pod-security.kubernetes.io/{enforce,audit,warn} labels on namespaces — and a "missing PSP" result is meaningless and must not be emitted. Only on a server genuinely older than 1.25 does the check fall back to PSP discovery.
  • Never score from object counts.
    • Scored security 90: RBAC has 200 bindings.
    • Scored security 40: 200 bindings exist, but one workload ServiceAccount is bound to a //* ClusterRole and three application namespaces carry no enforce label; credit stops at partial.
  • RBAC severity is about who holds the grant. A wildcard ClusterRole bound to a human break-glass group is a posture note; the same grant bound to a workload ServiceAccount (especially one reachable from a public ingress) is high — a pod compromise becomes cluster compromise.
  • Errors are evidence. An RBAC Forbidden on a specific list means the audit credential lacks that view grant; record blocked for that check with the exact resource, never convert it to a pass.
  • Captures keep object names, namespaces, kinds, and RBAC verbs/resources. Never capture Secret values; this audit never reads Secret data (kubectl get secret -o yaml is forbidden — only names/types via get secret without -o yaml).
  • Never print or write a secret value. Webhook URLs, API tokens, bearer/auth headers, cloud keys, and connection strings are captured by key name or type only, never by value — not into the terminal, evidence, findings.json, report.md, or a Slack brief. Follow the shared secret-redaction discipline; the redaction filter (skills/redaction/lib/redaction.sh, redact_file) masks any residual secret in a written artifact as defense-in-depth.
  • Per-service loops key on namespace + name, never bare service name. One estate routinely runs the same service name in two namespaces (a redis in two application namespaces, staging and prod copies side by side); a name-only key silently collapses them into one coverage row or one probe target. Every loop over topology services reads attributes.namespace together with name from the export, and <namespace>/<name> is the unit for coverage rows, live-runtime probes, and affected entries.

Estate sizing

Count before judging, and declare the path in the terminal output:

set -eu
CFG="${SCOUTFLO_CONFIG:-}"; [ -n "$CFG" ] || for _c in "./.scoutflo/toolkit.yaml" "$(cat "$HOME/.scoutflo/active-config" 2>/dev/null || true)" "$HOME/.scoutflo/toolkit.yaml"; do [ -f "$_c" ] && { CFG="$_c"; break; }; done; [ -n "$CFG" ] || CFG="$HOME/.scoutflo/toolkit.yaml"
# Resolve the CURRENT kubernetes target (single block or SCOUTFLO_TARGET-selected list item).
TT="${CLAUDE_PLUGIN_ROOT:-.}/report-standard/toolkit-targets.sh"
K8S_N=$(sh "$TT" "$CFG" kubernetes count)
K8S_IDX=0; if [ -n "${SCOUTFLO_TARGET:-}" ]; then _i=0; while [ "$_i" -lt "$K8S_N" ]; do [ "$(sh "$TT" "$CFG" kubernetes label "$_i")" = "$SCOUTFLO_TARGET" ] && { K8S_IDX=$_i; break; }; _i=$((_i+1)); done; fi
KUBE_CONTEXT=$(sh "$TT" "$CFG" kubernetes get "$K8S_IDX" context)
OBJ="$(kubectl --context "$KUBE_CONTEXT" get pods,deploy,sts,ds,svc,networkpolicy,pdb -A -o json 2>/dev/null | jq '.items | length')"
if   [ "$OBJ" -lt 200 ];  then P=small
elif [ "$OBJ" -lt 1000 ]; then P=medium
elif [ "$OBJ" -lt 4000 ]; then P=large
else P=xlarge; fi
echo "estate: ${OBJ} objects, ${P} path"

Record estate: {objects, path} in findings.json. On xlarge, scope by namespace (ask the user which namespaces matter) rather than listing every object cluster-wide in one pass.

Scope checkpoint

On a large estate this audit pauses to let you scope before spending tokens, per the shared estate-sizing scope checkpoint. After the sizing step above computes the object count, run the shared checkpoint block:

set -eu
# audit-kubernetes sizing counts objects into OBJ, not TOTAL; set TOTAL from the estate object count computed above.
TOTAL="${OBJ:?estate sizing must set OBJ (the estate object count) before the scope checkpoint}"
: "${TOTAL:?estate sizing must set TOTAL before the scope checkpoint}"
. "${CLAUDE_PLUGIN_ROOT}/skills/cli-interactive/lib/cli-interactive.sh"
. "${CLAUDE_PLUGIN_ROOT}/skills/checkpoint/lib/checkpoint.sh"
SCOPE="$(checkpoint_load_scope)"                # reuse a saved scope, or "all"
[ "$SCOPE" = "all" ] || echo "[checkpoint] reusing saved audit scope: ${SCOPE}"
if [ "${TOTAL}" -ge 501 ]; then
  echo "estate: ${TOTAL} objects (large path) — pausing to let you scope before spending tokens"
  cli_pause_before_audit "${TOTAL}"             # confirm before a large run
  cli_prompt_exclude_services                   # offer service/region exclusions
  echo "[checkpoint] narrow scope any time with /scoutflo:checkpoint; reset with /scoutflo:checkpoint --reset-scope"
fi

The large-path phases then run against the scoped set; the report names anything scoped out.

Phase 1: Service context

If ./scoutflo-audits/topology.md or a business-context file names critical services, load them (see Metadata Load below). They set which workloads make the Reliability checks high vs medium and which namespaces are "application namespaces" for the network-policy check. Without them, treat every non-system namespace as an application namespace and every workload equally.

Resolve each critical service to its <namespace>/<name> pair here, once — attributes.namespace plus name from topology-export.json — and carry that pair through every later phase (per the ground rule above). Two services that share a name in different namespaces are two entries from this point on, never one.

Phase 2: Read-only inventory

Build the raw picture with the commands in references/kubernetes-checks.md section 2: server version; namespaces with their pod-security.kubernetes.io/* labels; ClusterRoles/ClusterRoleBindings/Roles/RoleBindings; NetworkPolicies per namespace; Deployments/StatefulSets/DaemonSets with their container resource requests/limits; PodDisruptionBudgets and replica counts. Judgment starts in Phase 3.

Phases 3–7 are the five scored categories. Each finding must clear the depth doctrine: name the exact object and value, compute the blast radius from this cluster (who reaches what, what goes down), state the correlation chain when one exists, and give the specific fix. "X is missing" is a linter line, not a finding. Commands and the per-check blast-radius/correlation notes are in references/kubernetes-checks.md sections 3–7.

Phase 3: Workload hardening (K8S-001, K8S-007, K8S-008)

Commands in section 3. K8S-001 — on 1.25+, every application namespace should enforce a pod-security.kubernetes.io/enforce standard at baseline or restricted (high when none is enforced — pods run unconstrained). K8S-007 — no application workload runs privileged, a host namespace (hostNetwork/hostPID/hostIPC), a hostPath mount, or SYS_ADMIN/NET_ADMIN capabilities (high — a node-escape surface; critical when the same workload is internet-exposed per K8S-010 or its SA is privileged per K8S-002). CNI/CSI/node-exporter DaemonSets in system namespaces legitimately need host access — a posture note, never a finding. K8S-008 — application containers meet the restricted baseline (runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false, drop ALL caps); a root container with a writable rootfs is the substrate for a post-exploit payload (medium; cite alongside K8S-007 when the same workload fails both). K8S-001 is the upstream cause — cite the specific K8S-007/008 violators as proof that enforce=none is not theoretical.

Phase 4: Identity & access (K8S-002, K8S-006, K8S-009)

Commands in section 4. K8S-002 — ClusterRoles granting * verbs on * resources in * API groups, and their ServiceAccount subjects (high when bound to a workload SA, critical when that workload is public per K8S-010). K8S-006 — a workload SA that can list secrets cluster-wide; confirm it with auth can-i list secrets --all-namespaces --as=system:serviceaccount:<ns>:<sa> — the returned yes/count is the blast radius, not an inference (high). K8S-009 — workloads that never call the API server should set automountServiceAccountToken: false; a mounted token matters exactly as much as its SA's RBAC, so rank K8S-009 findings by each SA's auth can-i result: a default SA with no bindings mounting a token is low; the payments pod mounting a secrets-reader token is high, and its fix names both the automount flag and the RBAC to narrow. Break-glass human/group bindings are posture notes.

Phase 5: Network segmentation (K8S-003, K8S-011, K8S-010)

Commands in section 5. K8S-003 — each application namespace has ≥1 NetworkPolicy selecting its pods (high when a namespace has zero — any pod reaches any other; name the reachable datastores by pod/port, not "flat network"). K8S-011 — a namespace with policies but no default-deny ingress is allow-by-omission: every pod no policy selects is still open (medium; name the uncovered pods). A namespace can pass K8S-003 and fail K8S-011. K8S-010 — inventory internet-facing Services (LoadBalancer/NodePort) and Ingress, resolve each to its backing workload, and compute the external→cluster path by joining K8S-007 (privileged?), K8S-002/006/009 (SA power?), and K8S-003 (segmented?). The one-sentence path — public Service → token-mounting pod → secrets-reader SA → unsegmented namespace — is the flagship finding no scanner assembles (high, critical when the exposed pod's SA is privileged).

Phase 6: Resource governance (K8S-004, K8S-014)

Commands in section 6. K8S-004 — Deployments/StatefulSets/DaemonSets whose containers set no limits (medium; requests-without-limits is a partial). Name the node-shared workloads an unbounded container can starve; if K8SRT-002 shows it already OOMKilled, mark validated-live. K8S-014 — application namespaces with neither a ResourceQuota nor a LimitRange (medium). The correlation with K8S-004 is the point: no LimitRange and no per-workload limits is truly unbounded with no admission backstop — and a LimitRange retro-fits sane defaults onto the K8S-004 offenders in one object, so name it as the cheap first move.

Phase 7: Reliability & resilience (K8S-005, K8S-012, K8S-013)

Commands in section 7. K8S-005 — critical single-replica workloads with no PodDisruptionBudget (medium, high for critical services); frame it as a scheduled-maintenance outage (a routine node drain takes it down), not a rare failure. K8S-012 — workloads missing readiness or liveness probes (medium): missing readiness sends deploy-time traffic to not-ready pods (user-visible 502s on every rollout); missing liveness leaves a wedged process unrestarted (silent brownout) — name which mode applies. K8S-013 — multi-replica critical workloads with no topologySpreadConstraints or pod anti-affinity (medium, high for critical): replicas: 3 reads as HA but the scheduler may co-locate all three, so one node loss is a full outage; use the live -o wide per-node count to say whether they are co-located right now and mark validated-live if they are. This is why K8S-005 passing is not the end of resilience.

Phase 8: Live-runtime snapshot (K8SRT — evidence, not scored)

The scored checks above judge configuration: what this cluster would do under attack or a node drain. This phase captures what the cluster is doing right now — current container restarts and waiting reasons, recent Warning events, and node/pod usage when metrics-server answers. It is a snapshot, not a trend and not a posture judgment, so it is a parallel non-scored section per the report template's pattern (the same way Scoutflo Topology Readiness and audit-aws's Cost section work): finding IDs K8SRT-NNN, area: live-runtime, always severity info and points_recoverable: 0, never present in score.categories or score.excluded. A crash-looping pod observed here moves no score in either direction — the scored posture gap (for example K8S-004, no memory limit) carries the points; the snapshot carries the current proof.

Every probe routes through the guarded wrapper in the shared live-evidence library (skills/live-evidence/lib/live-evidence.sh): allowlisted read verbs only, --context pinned explicitly, every call bounded by --request-timeout, probe output redacted. The read-only guarantee is enforced mechanically by ci/liveness-readonly-check.sh, not by promise. This phase uses get, get events, and top only; it never calls logs — that depth belongs to /scoutflo:rca.

Runs-anywhere fallback. When probes are unavailable — kubectl missing, context unresolvable, RBAC denied, cluster unreachable — the section renders skipped, reason: <the exact reason> and nothing else. A skipped snapshot is never a finding, never a fail, and never invented output; the scored audit is complete without it. The same rule applies per probe: top on a cluster without metrics-server is skipped with that reason, never estimated.

set -eu
CFG="${SCOUTFLO_CONFIG:-}"; [ -n "$CFG" ] || for _c in "./.scoutflo/toolkit.yaml" "$(cat "$HOME/.scoutflo/active-config" 2>/dev/null || true)" "$HOME/.scoutflo/toolkit.yaml"; do [ -f "$_c" ] && { CFG="$_c"; break; }; done; [ -n "$CFG" ] || CFG="$HOME/.scoutflo/toolkit.yaml"
# Resolve the CURRENT kubernetes target (single block or SCOUTFLO_TARGET-selected list item).
TT="${CLAUDE_PLUGIN_ROOT:-.}/report-standard/toolkit-targets.sh"
K8S_N=$(sh "$TT" "$CFG" kubernetes count)
K8S_IDX=0; if [ -n "${SCOUTFLO_TARGET:-}" ]; then _i=0; while [ "$_i" -lt "$K8S_N" ]; do [ "$(sh "$TT" "$CFG" kubernetes label "$_i")" = "$SCOUTFLO_TARGET" ] && { K8S_IDX=$_i; break; }; _i=$((_i+1)); done; fi
KUBE_CONTEXT=$(sh "$TT" "$CFG" kubernetes get "$K8S_IDX" context)
SNAP_TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"   # tag every fact below [live@${SNAP_TS}]
# Redaction first, then the guarded probe lib (le_kubectl allows read verbs only).
. "${CLAUDE_PLUGIN_ROOT}/skills/redaction/lib/redaction.sh" 2>/dev/null || true
. "${CLAUDE_PLUGIN_ROOT}/skills/live-evidence/lib/live-evidence.sh"
if ! REASON="$(le_can_probe "$KUBE_CONTEXT" 2>&1 >/dev/null)"; then
  echo "live-runtime snapshot: skipped, reason: ${REASON:-no live access}"; exit 0
fi
echo "live-runtime snapshot @ ${SNAP_TS} (read-only, not scored)"

# 1. Containers restarting or stuck in a waiting state right now (K8SRT-001/002 evidence).
le_kubectl "$KUBE_CONTEXT" get pods -A -o json 2>/dev/null | jq -r '
  .items[] | . as $p | .status.containerStatuses[]?
  | select((.restartCount // 0) > 0 or ((.state.waiting.reason // "") != ""))
  | "\($p.metadata.namespace)/\($p.metadata.name)\t\(.name)\trestarts=\(.restartCount // 0)\twaiting=\(.state.waiting.reason // "-")\tlast=\(.lastState.terminated.reason // "-")/exit=\(.lastState.terminated.exitCode // "-")"' \
  || echo "pod snapshot: blocked (record the exact error; verdict unknown, never healthy)"

# 2. Recent Warning events, newest last (K8SRT-003 evidence).
le_kubectl "$KUBE_CONTEXT" get events -A --field-selector type=Warning --sort-by=.lastTimestamp -o json 2>/dev/null \
  | jq -r '.items[-20:] | .[] | "\(.lastTimestamp)\t\(.involvedObject.namespace // "-")/\(.involvedObject.name)\t\(.reason)\tcount=\(.count // 1)"' \
  || echo "warning events: blocked (record the exact error; verdict unknown, never healthy)"

# 3. Node/pod usage (K8SRT-004 evidence) — only when metrics-server answers.
if le_kubectl "$KUBE_CONTEXT" top nodes >/dev/null 2>&1; then
  le_kubectl "$KUBE_CONTEXT" top nodes 2>/dev/null || true
  le_kubectl "$KUBE_CONTEXT" top pods -A --sort-by=memory 2>/dev/null | head -15 || true
else
  echo "top nodes/pods: skipped, reason: metrics-server (metrics.k8s.io) not answering on this cluster, or RBAC denies it"
fi

# 4. Per-critical-service rollout state, keyed on namespace + name (never bare name).
TOPO="${SCOUTFLO_AUDIT_DIR:-./scoutflo-audits}/topology-export.json"
if [ -f "$TOPO" ]; then
  jq -r '.services[]? | select(.business_criticality == "critical" or .business_criticality == "high")
    | "\(.attributes.namespace // "default") \(.name)"' "$TOPO" | sort -u \
  | while read -r NS SVC; do
      [ -n "${SVC:-}" ] || continue
      echo "== ${NS}/${SVC} =="
      # probe_* return rc=0 with EMPTY stdout when blocked (the lib swallows errors),
      # so an `||` fallback is dead code — check output emptiness instead.
      _ro="$(probe_rollout "$KUBE_CONTEXT" "$NS" "$SVC" 2>/dev/null || true)"
      if [ -n "$_ro" ]; then printf '%s\n' "$_ro"; else echo "rollout probe blocked for ${NS}/${SVC} (verdict unknown, never healthy)"; fi
      _ev="$(probe_events "$KUBE_CONTEXT" "$NS" "$SVC" 2>/dev/null || true)"
      [ -n "$_ev" ] && printf '%s\n' "$_ev" || echo "events probe returned nothing for ${NS}/${SVC} (no recent events, or probe blocked)"
    done
else
  echo "per-service probes: skipped, reason: no topology-export.json (run /scoutflo:map-topology); the cluster-wide snapshot above still stands"
fi

When the export's DEPLOYED_AS edge or attributes.app names a workload different from the service name, probe that workload name instead; a probe that finds no object is recorded as unknown for that <namespace>/<name>, never as healthy and never re-guessed against another namespace.

When does a snapshot row become a K8SRT finding? Only when the exact taxonomy field is present, per the shared failure taxonomy in k8s-liveness-probes.md; the catalog and skip rules are in references/kubernetes-checks.md section 10. K8SRT-001: a container in a crash/backoff waiting state now (cite the waiting reason plus lastState.terminated reason/exit code). K8SRT-002: OOMKilled in the last termination (cite exit code 137; when K8S-004 flagged the same workload, also cite this observation in K8S-004's evidence and mark that finding validated-live — the posture finding carries the severity, the snapshot carries the proof). K8SRT-003: a Warning-event burst in an application namespace (cite reason, object, count). K8SRT-004: node/pod pressure from top output (only when metrics-server answered). A bare restartCount with no terminated reason or event is a symptom: it stays a snapshot table row, never a finding. A blocked probe is recorded skipped, reason: — verdict unknown, never healthy.

Render the section in report.md under its own heading, after Scoutflo Topology Readiness, per the report template's parallel non-scored section pattern. Open it with the mode line ([live@<SNAP_TS>], or the skip line with its reason), then the snapshot tables, then any K8SRT findings. Close with the pointer: for a verdict on anything distressed here, run /scoutflo:rca <namespace>/<service> — this section is evidence, not an RCA.

Phase 9: Coverage matrix and topology readiness

Per the report standard, render the per-service coverage matrix and the Scoutflo Topology Readiness section for the critical services named in Phase 1. Judge each service against six posture columns: hardened (namespace enforces pod security AND the workload is non-privileged/non-root — K8S-001/007/008), least-privilege (its SA holds no wildcard/secret-reader power and mounts a token only if it needs one — K8S-002/006/009), network-isolated (a NetworkPolicy selects it AND its namespace has a default-deny — K8S-003/011), resource-bounded (requests + limits, under a namespace LimitRange — K8S-004/014), resilient (PDB/replicas AND spread across nodes — K8S-005/013), and health-probed (readiness + liveness — K8S-012). A service missing all six is 0 of 6; state the count in plain language (see topology-readiness.md). Every matrix row is keyed <namespace>/<name> (per the ground rule): two same-named services in different namespaces get two rows, each judged against its own namespace's labels, policies, PDBs, and spread rules.

Phase 10: Score, write, brief

Score per severity-and-scoring.md: each check yields pass (1.0), partial (0.5), or fail (0). blocked is unassessed and leaves the readiness denominator; not-in-scope (e.g. the PSP branch on a 1.25+ server) leaves both the readiness and the assessment-coverage denominators. Category score is the assessed-credit ratio times 100, rounded down; overall is the weight-normalized sum over categories with at least one assessed check. Show assessment coverage separately. A category with no assessed checks is excluded, renormalized, and stated. A fully blocked run is unassessed with overall: null, never 0/100. When unsure between a defect and missing evidence, use blocked and state the exact evidence-unlock action.

Category Weight Checks
Workload hardening 25 K8S-001 (pod security admission), K8S-007 (host-namespace/privileged escape), K8S-008 (container restricted baseline)
Identity & access 20 K8S-002 (wildcard/cluster-admin RBAC), K8S-006 (cluster-wide secret readers), K8S-009 (service-account token exposure)
Network segmentation 20 K8S-003 (network policy presence), K8S-011 (default-deny baseline), K8S-010 (external exposure surface)
Reliability & resilience 20 K8S-005 (PDB/replicas), K8S-012 (health probes), K8S-013 (replica spread)
Resource governance 15 K8S-004 (requests + limits), K8S-014 (namespace quota/LimitRange)

Full check catalog and target profile at the top of references/kubernetes-checks.md. IDs are stable: the same defect gets the same ID every run, one finding per failed check, affected objects (namespace/kind/name) enumerated in affected. Compute points_recoverable per finding by re-running the scoring model with that check at full credit; info findings carry 0. Live-runtime (K8SRT-*) findings are always info with points_recoverable: 0, stay outside the readiness checks[] ledger, explicitly carry scoring_scope: "non-scored", and never enter this arithmetic — the snapshot can corroborate a posture finding's evidence (and upgrade its status to validated-live), but it never moves the score in either direction. The executive summary states the gap to target and the two or three highest-points_recoverable findings as the biggest levers.

End-to-end gate: claim end-to-end coverage only when the overall score is at or above 85, assessment coverage is 100%, every critical service passes every applicable coverage row, and no category was excluded. Below the gate, write "good base posture", never "end to end".

Write findings.json first (canonical), then regenerate report.md, the history line, and the Slack brief from it. Since findings.json uses scoutflo-findings/v2, resolve lifecycle, exemptions, and the check ledger before rendering the report:

  1. Load the previous run's findings.json when one exists; classify every finding, K8S-* and K8SRT-* alike, per the lifecycle table in the findings schema (new, unchanged, regressed; resolved IDs go to the delta, and the executive summary names regressions first).
  2. Load ./scoutflo-audits/exemptions.yaml when present. Entries with id, reason, and expires all set and unexpired suppress their finding into the Suppressed appendix; malformed or expired entries are reported, never honored. For a readiness finding, retain the observed partial or fail result on the same-ID checks[] row and add suppressed: true plus suppression_reason; set the finding's points_recoverable to 0. Suppressed readiness checks remain assessed for coverage but are excluded from readiness scoring. A non-scored K8SRT-* finding has no check row: set only its lifecycle to suppressed, preserve scoring_scope: "non-scored", and keep zero readiness points.
  3. Every findings area and coverage cell carries its denominator (passed/total).
  4. Emit one checks[] row for every stable K8S-* readiness catalog check ({id, category, result} with result ∈ pass|partial|fail|blocked|not-in-scope, and a non-empty reason on any partial/blocked/not-in-scope row), including passes, partials, failures, blockers, and not-in-scope checks. Derive category counts, readiness, assessment coverage, and score.check_set (the cksum-v2:N:M fingerprint over each check's id + category, every category's name + weight, and the gate, per the pipeline in findings-schema.md) from that complete ledger; never write them independently. A category with zero assessed checks goes in score.excluded[] and out of overall. K8SRT-* findings stay outside the readiness ledger and explicitly carry scoring_scope: "non-scored".
  5. Every finding declares scoring_scope (readiness for a same-ID non-pass K8S-* check; non-scored for every K8SRT-*) and report_lanes: a unique, non-empty subset of general-audit and ai-sre-readiness. Default to general-audit (operational reliability and security posture); add/also use ai-sre-readiness only when the finding bears on telemetry quality, service identity/naming, topology/ownership context, incident routing evidence, RCA trust, or action safety — what trustworthy AI-assisted diagnosis needs. The flagship external-exposure blast-radius path (K8S-010) and the RBAC-power findings (K8S-002/006/009) inform the blast-radius and action-safety reasoning an AI SRE relies on, so those are typically both lanes; a pure hardening, network-segmentation, resource-limit, or resilience gap (K8S-001/007/008, K8S-003/011, K8S-004/014, K8S-005/012/013) is general-audit only. This classification never changes severity or score. Referential-integrity rules the gate enforces: every partial/fail/blocked check has a same-ID readiness finding, every readiness finding has a same-ID non-pass check row (never pass/not-in-scope), a blocked check's finding carries status: "blocked" and points_recoverable: 0, and a non-scored finding has points_recoverable: 0 and no check row.

Then run the report-standard self-validation, exactly as every other audit does — check-findings.sh (score reconciles with the scorecard, the v2 checks[] ledger and schema invariants hold) before check-report.sh (shape):

set -eu
CFG="${SCOUTFLO_CONFIG:-}"; [ -n "$CFG" ] || for _c in "./.scoutflo/toolkit.yaml" "$(cat "$HOME/.scoutflo/active-config" 2>/dev/null || true)" "$HOME/.scoutflo/toolkit.yaml"; do [ -f "$_c" ] && { CFG="$_c"; break; }; done; [ -n "$CFG" ] || CFG="$HOME/.scoutflo/toolkit.yaml"
# Resolve the CURRENT kubernetes target so output lands in the per-target directory:
# kubernetes/<context>/<date>/ for a single block, kubernetes/<label>/<date>/ for a labeled list.
TT="${CLAUDE_PLUGIN_ROOT:-.}/report-standard/toolkit-targets.sh"
K8S_KIND=$(sh "$TT" "$CFG" kubernetes kind); K8S_N=$(sh "$TT" "$CFG" kubernetes count)
K8S_IDX=0; if [ -n "${SCOUTFLO_TARGET:-}" ]; then _i=0; while [ "$_i" -lt "$K8S_N" ]; do [ "$(sh "$TT" "$CFG" kubernetes label "$_i")" = "$SCOUTFLO_TARGET" ] && { K8S_IDX=$_i; break; }; _i=$((_i+1)); done; fi
K8S_LABEL=$(sh "$TT" "$CFG" kubernetes label "$K8S_IDX"); KUBE_CONTEXT=$(sh "$TT" "$CFG" kubernetes get "$K8S_IDX" context)
if [ "$K8S_KIND" = seq ]; then K8S_SEG="kubernetes/${K8S_LABEL}"; else K8S_SEG="kubernetes/${KUBE_CONTEXT}"; fi
RUN_DATE="$(date -u +%Y-%m-%d)"
OUT="${SCOUTFLO_AUDIT_DIR:-./scoutflo-audits}/${K8S_SEG}/${RUN_DATE}"   # per-target segment, matching the declared outputs
mkdir -p "$OUT"
# The findings.json/inventory.json ".target" is the per-target slug $K8S_SEG ("kubernetes/<context>" for a
# single block, "kubernetes/<label>" for a labeled list target), so audit-all/correlation/render disambiguate
# multiple clusters. Verify the v2 envelope first (schema, target, a non-empty checks[] ledger, and every
# finding carrying lifecycle + scoring_scope + non-empty report_lanes), then reconcile score + shape.
jq -e --arg seg "$K8S_SEG" '.schema == "scoutflo-findings/v2" and .target == $seg
  and (.checks | type == "array" and length > 0)
  and (.findings | type == "array")
  and (.estate.path != null)
  and (.findings | all(has("lifecycle") and (.scoring_scope | IN("readiness","non-scored")) and (.report_lanes | type == "array" and length > 0)))' \
  "$OUT/findings.json" >/dev/null && echo "findings.json valid"
sh "${CLAUDE_PLUGIN_ROOT}/report-standard/check-findings.sh" "$OUT/findings.json"
# Inventory (scoutflo-inventory/v1): the complete Phase-1 catalog of what exists,
# built from the raw pull (never invented, redacted). counts.total must reconcile
# with items; the ## Inventory section of report.md IS this render.
jq -e '.schema == "scoutflo-inventory/v1" and (.items | type == "array") and (.counts.total == (.items | length))' "$OUT/inventory.json" >/dev/null && echo "inventory.json valid"
sh "${CLAUDE_PLUGIN_ROOT}/report-standard/render-report-viz.sh" inventory "$OUT/inventory.json" >/dev/null && echo "inventory section renders"
sh "${CLAUDE_PLUGIN_ROOT}/report-standard/render-report-viz.sh" lanes "$OUT/findings.json" >/dev/null && echo "findings-by-purpose section renders"
grep -qxF '## Findings by purpose' "$OUT/report.md" && echo "findings-by-purpose section present"
sh "${CLAUDE_PLUGIN_ROOT}/report-standard/render-report-viz.sh" html "$OUT/findings.json" "$OUT/report.html" "$(dirname "$OUT")/history.jsonl"
sh "${CLAUDE_PLUGIN_ROOT}/report-standard/check-report.sh" "$OUT/report.md"

# Append the derived v2 history row after findings/report validation. A same-date
# rerun replaces that date's row instead of duplicating it.
TARGET_DIR="$(dirname "$OUT")"
RESOLVED="0"   # fixed count from this run's delta; 0 on the first run
LINE="$(jq -c --arg d "$RUN_DATE" --argjson resolved "$RESOLVED" \
  '{run_date:$d, skill:"audit-kubernetes", overall:.score.overall, state:.score.state,
    scoring_model:.score.scoring_model, check_set:.score.check_set,
    assessment_coverage_percent:.score.assessment.coverage_percent, gate:.score.gate,
    end_to_end:.score.end_to_end, severity_counts:.severity_counts,
    lifecycle_counts:((reduce .findings[].lifecycle as $l ({}; .[$l] = (.[$l] // 0) + 1)) + {resolved:$resolved})}' \
  "$OUT/findings.json")"
TMP="$(mktemp)"
[ -f "${TARGET_DIR}/history.jsonl" ] && grep -v "\"run_date\":\"${RUN_DATE}\"" "${TARGET_DIR}/history.jsonl" > "$TMP" || true
printf '%s\n' "$LINE" >> "$TMP"
mv "$TMP" "${TARGET_DIR}/history.jsonl"
tail -1 "${TARGET_DIR}/history.jsonl" | jq -e '.run_date and ((.overall|type)=="number" or .overall==null) and .scoring_model and .check_set' >/dev/null && echo "history.jsonl updated"

Compute the delta against the previous run date per the report standard; on the first run state "first run, no delta". After the report is written, close with the run-completion message per the report standard (report-template.md): the one-line score headline (with movement and the "good base posture" / not-end-to-end label), the top fixes by points_recoverable, the absolute report.md path, the OS-specific open command, and the leak-safe share pointer (the Slack brief). Then send the Slack brief — titles only, never evidence values, no namespaces or object names:

set -eu
CFG="${SCOUTFLO_CONFIG:-}"; [ -n "$CFG" ] || for _c in "./.scoutflo/toolkit.yaml" "$(cat "$HOME/.scoutflo/active-config" 2>/dev/null || true)" "$HOME/.scoutflo/toolkit.yaml"; do [ -f "$_c" ] && { CFG="$_c"; break; }; done; [ -n "$CFG" ] || CFG="$HOME/.scoutflo/toolkit.yaml"
# Resolve the CURRENT kubernetes target so the brief reads the per-target run dir (matching the emit step).
TT="${CLAUDE_PLUGIN_ROOT:-.}/report-standard/toolkit-targets.sh"
K8S_KIND=$(sh "$TT" "$CFG" kubernetes kind); K8S_N=$(sh "$TT" "$CFG" kubernetes count)
K8S_IDX=0; if [ -n "${SCOUTFLO_TARGET:-}" ]; then _i=0; while [ "$_i" -lt "$K8S_N" ]; do [ "$(sh "$TT" "$CFG" kubernetes label "$_i")" = "$SCOUTFLO_TARGET" ] && { K8S_IDX=$_i; break; }; _i=$((_i+1)); done; fi
K8S_LABEL=$(sh "$TT" "$CFG" kubernetes label "$K8S_IDX"); KUBE_CONTEXT=$(sh "$TT" "$CFG" kubernetes get "$K8S_IDX" context)
if [ "$K8S_KIND" = seq ]; then K8S_SEG="kubernetes/${K8S_LABEL}"; else K8S_SEG="kubernetes/${KUBE_CONTEXT}"; fi
TARGET_DIR="${SCOUTFLO_AUDIT_DIR:-./scoutflo-audits}/${K8S_SEG}"
RUN_DATE="$(date -u +%Y-%m-%d)"
OUT="${TARGET_DIR}/${RUN_DATE}"
RT_LINE=""   # optional; set only when live-runtime (K8SRT) observations exist this run
# slack.webhook_env names the webhook variable; skip when unset.
if [ -n "${SCOUTFLO_SLACK_WEBHOOK:-}" ]; then
  OUT_ABS="$(cd "$OUT" && pwd)"   # absolute path: the brief must be openable from anywhere
  SCORE="$(jq -r '.score.overall' "$OUT/findings.json")"
  SCORE_STATE="$(jq -r '.score.state' "$OUT/findings.json")"
  CUR_MODEL="$(jq -r '.score.scoring_model' "$OUT/findings.json")"
  CUR_SET="$(jq -r '.score.check_set' "$OUT/findings.json")"
  ASSESSMENT="$(jq -r '.score.assessment | "\(.assessed_checks)/\(.applicable_checks) (\(.coverage_percent)%) assessed, \(.scored_checks) scored, \(.blocked_checks) blocked, \(.suppressed_checks) suppressed"' "$OUT/findings.json")"
  E2E="$(jq -r 'if .score.end_to_end then "end-to-end" else "not end-to-end" end' "$OUT/findings.json")"
  COUNTS="$(jq -r '.severity_counts | "\(.critical) critical, \(.high) high, \(.medium) medium, \(.low) low"' "$OUT/findings.json")"
  # Top findings are the scored posture levers; the non-scored live-runtime snapshot gets its own count line.
  TOP="$(jq -r '[.findings[] | select((.lifecycle // "new") != "suppressed") | select(.area != "live-runtime") | "\(.id) \(.title)"] | .[0:5] | join("\n")' "$OUT/findings.json")"
  RT_COUNT="$(jq -r '[.findings[] | select(.area == "live-runtime")] | length' "$OUT/findings.json")"
  [ "$RT_COUNT" -gt 0 ] && RT_LINE="Live-runtime: ${RT_COUNT} runtime observations (snapshot, not scored)"
  # Date-named run dirs only (the previous run's baseline for movement + delta).
  PREV="$(find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -type d -name '[0-9]*-[0-9]*-[0-9]*' | sort | tail -2 | head -1)"
  MOVE=""; DELTA="first run"
  if [ -n "$PREV" ] && [ "$PREV" != "$OUT" ]; then
    PREV_MODEL="$(jq -r '.score.scoring_model // ""' "$PREV/findings.json")"
    PREV_SET="$(jq -r '.score.check_set // ""' "$PREV/findings.json")"
    PREV_SCORE="$(jq -r 'if (.score.overall|type)=="number" then .score.overall else "" end' "$PREV/findings.json")"
    # Only show movement when both runs are assessed under the SAME scoring model and check_set;
    # a re-weight or catalog change makes the prior score not comparable (would fabricate a delta).
    if [ "$SCORE_STATE" = "assessed" ] && [ -n "$PREV_SCORE" ] && [ "$PREV_MODEL" = "$CUR_MODEL" ] && [ "$PREV_SET" = "$CUR_SET" ]; then
    MOVE="$(jq -rn --argjson prev "$(jq '.score.overall' "$PREV/findings.json")" --argjson cur "$SCORE" \
      '(($cur - $prev) | if . >= 0 then "(+\(.))" else "(\(.))" end)')"
    fi
    DELTA="$(jq -rn --slurpfile p "$PREV/findings.json" --slurpfile c "$OUT/findings.json" '
      [$p[0].findings[].id] as $b | [$c[0].findings[].id] as $n |
      "\(($b - $n) | length) fixed, \(($n - $b) | length) new, \(($n - ($n - $b)) | length) unchanged"')"
  fi
  if [ "$SCORE_STATE" = "unassessed" ]; then
    HEAD="audit-kubernetes ${RUN_DATE}: readiness unassessed; ${ASSESSMENT}. ${COUNTS}."
  else
    HEAD="audit-kubernetes ${RUN_DATE}: ${SCORE}/100${MOVE:+ $MOVE}, ${E2E}; ${ASSESSMENT}. ${COUNTS}."
  fi
  jq -n --arg head "$HEAD" \
        --arg top "$TOP" --arg delta "$DELTA" --arg rt "$RT_LINE" --arg path "$OUT_ABS/report.md" \
        '{text: ($head + "\nTop findings:\n" + $top + "\nDelta: " + $delta + ($rt | if . == "" then "" else "\n" + . end) + "\nReport: " + $path)}' \
    | curl -fsS --max-time 10 -H 'Content-Type: application/json' -d @- "$SCOUTFLO_SLACK_WEBHOOK" \
    || echo "Slack brief failed to send; audit result unaffected"
fi

When invoked by audit-all, skip the Slack brief; the orchestrator sends exactly one combined message. Keep ./scoutflo-audits/ out of public version control; reports describe your infrastructure.

Metadata Load (v0.1.68+)

This skill reads the optional business-context SSOT to honor your guardrails:

set -eu
BC_JSON="${HOME}/.scoutflo/business_context.json"      # workspace projection, derived from the SSOT
BC_MD="${HOME}/.scoutflo/business_context.md"          # the SSOT itself (authoritative)
METADATA="${HOME}/.scoutflo/computed_metadata.jsonl"   # per-resource cache from business-context-resolver

# The workspace layer and the per-resource layer load TOGETHER, not either/or.
HAVE_PER_RESOURCE=0; HAVE_WORKSPACE=0
[ -f "$METADATA" ] && jq -e '.' "$METADATA" >/dev/null 2>&1 && HAVE_PER_RESOURCE=1
[ -f "$BC_JSON" ]  && jq -e '.' "$BC_JSON"  >/dev/null 2>&1 && HAVE_WORKSPACE=1
# Workspace source: the derived json, else the markdown SSOT directly (ssot-md fallback).
BC_SRC=""
if [ "$HAVE_WORKSPACE" -eq 1 ]; then BC_SRC="$BC_JSON"; elif [ -f "$BC_MD" ]; then BC_SRC="$BC_MD"; fi
if   [ "$HAVE_PER_RESOURCE" -eq 1 ] && [ "$HAVE_WORKSPACE" -eq 1 ]; then LOAD_METADATA_MODE="per-resource+workspace"
elif [ "$HAVE_PER_RESOURCE" -eq 1 ];                                then LOAD_METADATA_MODE="per-resource"
elif [ "$HAVE_WORKSPACE" -eq 1 ];                                   then LOAD_METADATA_MODE="workspace"
elif [ -n "$BC_SRC" ];                                              then LOAD_METADATA_MODE="ssot-md"
else                                                                     LOAD_METADATA_MODE="none"; fi
echo "metadata mode: $LOAD_METADATA_MODE"

# Load the workspace rules the apply step below honors. All fields optional; absence = neutral default.
if [ "$HAVE_WORKSPACE" -eq 1 ]; then
  ENVIRONMENT="$(jq -r '.environment // "production"' "$BC_JSON" 2>/dev/null || echo production)"
  COST_SENSITIVITY="$(jq -r '.cost_sensitivity // "medium"' "$BC_JSON" 2>/dev/null || echo medium)"
  CRITICAL="$(jq -r '.critical_dependencies[]? // empty' "$BC_JSON" 2>/dev/null || true)"
  EXCLUSIONS="$(jq -r '.exclusions // {} | [.accounts?, .regions?, .services?, .resources?] | add // [] | .[]? // empty' "$BC_JSON" 2>/dev/null || true)"
  jq -r --arg e "$ENVIRONMENT" '.environment_map[]? | select(.environment==$e)' "$BC_JSON" 2>/dev/null || true  # per-env profile/project/context + uptime_sla
  jq -r '.service_slas[]? | "\(.service)=\(.sla)"' "$BC_JSON" 2>/dev/null || true                               # per-service SLA (wins over the env default)
elif [ "$LOAD_METADATA_MODE" = "ssot-md" ]; then
  # Only business_context.md exists (json not derived): read the same rules from the SSOT directly.
  ENVIRONMENT="$(grep -iA5 '^## Environment' "$BC_MD" | grep -iE 'Stage:' | head -1 | sed -E 's/.*Stage:\**[[:space:]]*//; s/[][]//g; s/[[:space:]]*$//' | tr 'A-Z' 'a-z')"; [ -n "$ENVIRONMENT" ] || ENVIRONMENT="production"
  COST_SENSITIVITY="$(grep -iA3 '^## Cost Sensitivity' "$BC_MD" | grep -iE 'Primary:' | head -1 | sed -E 's/.*Primary:\**[[:space:]]*//; s/[][]//g; s/[[:space:]]*$//' | tr 'A-Z' 'a-z')"; [ -n "$COST_SENSITIVITY" ] || COST_SENSITIVITY="medium"
  CRITICAL="$(awk '/^## Critical Services/{f=1;next} /^## /{f=0} f' "$BC_MD" | grep -oE '`[^`]+`' | tr -d '`')"
  EXCLUSIONS="$(awk '/^## Exclusions/{f=1;next} /^## /{f=0} f' "$BC_MD" | grep -oE '`[^`]+`' | tr -d '`')"
fi
# When HAVE_PER_RESOURCE=1, look each finding's affected resource up in computed_metadata.jsonl and let
# its per-resource action/escalation/sla refine (never weaken) the workspace rule for that one resource.

When context is available, apply it per BUSINESS-CONTEXT-INTEGRATION-v0168.md: exclude namespaces/resources matched by an exclusion (record them not-in-scope with the reason, never a fail); escalate findings on a critical_dependencies service (raise Reliability findings to high, mark it in the coverage matrix); reduce severity for a gap that exists only in a non-production environment; and apply cost_sensitivity to ordering. With no context, run neutral defaults and say so — never invent a business rule.

Remediation pointers

Finding family Fix path
No pod-security enforcement (K8S-001) Label application namespaces pod-security.kubernetes.io/enforce=baseline (then restricted once workloads comply) — setup-kubernetes#enforce-pod-security
Over-permissioned workload RBAC (K8S-002, K8S-006) Replace the wildcard/cluster-admin binding with a least-privilege Role scoped to the namespace and verbs the workload needs — setup-kubernetes#tighten-rbac
Flat pod network (K8S-003) Add a default-deny ingress NetworkPolicy per application namespace, then allow-list the flows — setup-kubernetes#add-network-policies
Missing resource limits (K8S-004) Set requests and limits on the flagged workloads (or a LimitRange on the namespace) — setup-kubernetes#set-resource-limits
Single-replica critical service, no PDB (K8S-005) Raise replicas and add a PodDisruptionBudget with minAvailable — setup-kubernetes#add-disruption-budgets

Exit codes

Code Meaning
0 Audit completed (findings may exist)
1 kubectl or jq not installed
2 kubernetes.context not found in kubeconfig, or unreachable
3 RBAC denied (insufficient view permissions to complete the audit)

Common Failure Modes

Failure Prevention
Auditing PodSecurityPolicy on a 1.25+ cluster Detect server version first; on 1.25+ evaluate Pod Security Admission labels, never PSP; PSP branch is not-in-scope and excluded from the score
Using the active kube-context instead of the configured one Every kubectl call passes `

Truncated - read the full file at https://github.com/Scoutflo/ai-readiness/blob/8753bf2d11da5f629f6017ec4da828f75c642b5e/skills/audit-kubernetes/SKILL.md.

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/scoutflo-ai-readiness-audit-kubernetes/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

scoutflo-ai-readiness-audit-kubernetes.ocm.jsonjson
{
  "ocm": "1",
  "id": "scoutflo-ai-readiness-audit-kubernetes",
  "kind": "skill",
  "name": "audit-kubernetes",
  "description": "Read-only scored audit of a Kubernetes cluster's security and operational posture — Pod Security Admission enforcement, RBAC over-permissioning, network-policy coverage, workload resource limits, and PodDisruptionBudget/replica resilience — that also reports a separate NON-SCORED live-runtime snapshot (current pod restarts and waiting reasons, recent warning events, node/pod usage when metrics-server answers) through guarded read-only probes; writes findings.json and report.md and changes nothing. Use when the user mentions auditing or scoring a Kubernetes/EKS/GKE/AKS cluster, pod security, cluster RBAC, network policies, missing resource limits, or single-replica critical workloads. Do not use to change the cluster (there is no in-cluster mutation here; findings name the fix), for in-cluster Prometheus/Grafana telemetry (use audit-lgtm / audit-grafana), for cloud-provider control-plane alarms (use audit-aws / audit-gcp), or for a root-cause verdict on a failing workload (use rca; the snapshot here is evidenc",
  "publisher": "Scoutflo",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "coding"
    ],
    "tags": [
      "skill-md",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "Read-only scored audit of a Kubernetes cluster's security and operational posture — Pod Security Admission enforcement, RBAC over-permissioning, network-policy coverage, workload resource limits, and PodDisruptionBudget/replica resilience — that also reports a separate NON-SCORED live-runtime snapshot (current pod restarts and waiting reasons, recent warning events, node/pod usage when metrics-server answers) through guarded read-only probes; writes findings.json and report.md and changes nothing. Use when the user mentions auditing or scoring a Kubernetes/EKS/GKE/AKS cluster, pod security, cluster RBAC, network policies, missing resource limits, or single-replica critical workloads. Do not use to change the cluster (there is no in-cluster mutation here; findings name the fix), for in-cluster Prometheus/Grafana telemetry (use audit-lgtm / audit-grafana), for cloud-provider control-plane alarms (use audit-aws / audit-gcp), or for a root-cause verdict on a failing workload (use rca; the snapshot here is evidenc"
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/Scoutflo/ai-readiness",
      "path": "skills/audit-kubernetes/SKILL.md",
      "ref": "8753bf2d11da5f629f6017ec4da828f75c642b5e",
      "url": "https://github.com/Scoutflo/ai-readiness/blob/8753bf2d11da5f629f6017ec4da828f75c642b5e/skills/audit-kubernetes/SKILL.md",
      "key": "Scoutflo/ai-readiness/skills/audit-kubernetes/SKILL.md"
    }
  },
  "instructions": "# audit-kubernetes\n\nScored, read-only audit of a Kubernetes cluster's security and reliability posture: whether pods run under an enforced security standard, whether any workload identity holds far more RBAC than it needs, whether the pod network is segmented, whether workloads declare resource limits, and whether critical workloads can survive a node drain. It answers one question: if this cluster is attacked or loses a node tonight, does its own configuration contain the blast radius, or amplify it?\n\nThis audit reads cluster **objects** (via `kubectl get`/`auth can-i`). Whether the in-cluste",
  "cost": {
    "context_tokens": 15796
  }
}

Fetch it by URL: GET /api/v1/registry/scoutflo-ai-readiness-audit-kubernetes/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.