New to Kestra?
Use blueprints to kickstart your first workflows.
Audit Kubernetes Deployments for Secrets injected as env vars instead of volumes, alert on breach, and flag them for review.
A Secret mounted as a volume lives only as a file inside the container's filesystem.
The same Secret injected through env[].valueFrom.secretKeyRef lives in the
container's process environment - visible to kubectl describe pod, inherited by every
child process, and far more likely to end up in a crash dump or a verbose log line. CIS
Kubernetes Benchmark guidance flags this pattern, but nothing in the cluster itself
alerts you when a new Deployment ships with it. This blueprint lists every Deployment
in a namespace, flags the ones whose pod template exposes a Secret this way, alerts
Slack on breach, and can mark the offending Deployments with a review annotation -
never touching the container spec, the environment variable, or the Secret itself.
periodic_exposure_check (io.kestra.plugin.core.trigger.Schedule) runs hourly, always forcing auto_remediate: "false" and dry_run: "true" through its own inputs: override regardless of this flow's defaults. Shipped disabled so you can validate a manual run first.list_deployments (io.kestra.plugin.kubernetes.kubectl.Get) fetches every Deployment in k8s_namespace with fetchType: STORE, returning a uri to the raw DeploymentList JSON - the only fetch mode that carries the full spec.template.spec this audit needs; FETCH/FETCH_ONE only return metadataItems/statusItems.evaluate_secret_exposure (io.kestra.plugin.scripts.python.Script) walks every container in every Deployment's pod template and flags any Deployment with at least one env[].valueFrom.secretKeyRef, emitting flagged_count and flagged_deployments through Kestra.outputs.evaluate_exposure_gate (io.kestra.plugin.core.flow.If) flags a breach whenever flagged_count > max_env_secret_refs.then (breach): route_remediation (io.kestra.plugin.core.flow.Switch on auto_remediate) either considers remediation ("true", gated again by dry_run through check_dry_run) or only logs that remediation is disabled ("false"). When both gates allow it, flag_exposed_deployments (io.kestra.plugin.core.flow.Loop) runs annotate_exposed_deployment (io.kestra.plugin.kubernetes.kubectl.Patch, JSON_MERGE) for each flagged Deployment, adding only a security.kestra.io/secret-env-exposure: "flagged" annotation. alert_secret_exposure_risk always fires to Slack regardless of which path ran.else (compliant): log_exposure_healthy records a clean check.log_audit_complete always runs last, printing the headline numbers regardless of which branch fired.errors block alerts Slack separately if the flow itself fails - an unreachable API server or insufficient RBAC must not read as "no breach".auto_remediate, dry_run) between "exposure detected" and "Deployment actually flagged".secretKeyRef in an env: block.kubectl get and kubectl patch are one-shot commands with no memory of the last check, no threshold logic, and no notification path. Kestra supplies the schedule, the exposure classification as a first-class branch, two independent authorization gates before anything is annotated, and an execution history that shows exactly when a Deployment started exposing a Secret this way and whether it was flagged.
k8s_namespace.Local testing:
docker run -d --name k8s-secret-sentinel -p 6444:6443 rancher/k3s:latest server --tls-san=localhost --disable=traefik (or any local cluster - k3s, kind, minikube - reachable from the Kestra Worker).
docker network connect YOUR_KESTRA_NETWORK k8s-secret-sentinel (only needed if the Kestra Worker runs in a separate Docker network than this container; the exact master URL and token still need to be pulled from your chosen local cluster's own kubeconfig, which varies by tool - this has not been exercised end to end in this environment).
K8S_MASTER_URL: Kubernetes API server URL, matching this plugin's own documented connection pattern (see kubernetes-vault-rotation-restart.yaml).K8S_TOKEN: OAuth token used to authenticate against the cluster.SLACK_WEBHOOK_URL: Slack incoming webhook used by alert_secret_exposure_risk and the errors block.SECRET_, base64-encoded, and read back in flows with {{ secret('NAME') }} - for example SECRET_K8S_TOKEN=$(echo -n '<your-cluster-token>' | base64). This keeps credentials out of the flow YAML but, per Kestra's own documentation, offers no encryption at rest or access control beyond the host environment; use the Enterprise secrets backend for stronger guarantees.k8s_namespace (STRING, default default): namespace audited.max_env_secret_refs (INT, default 0): breach threshold; 0 means any exposed Deployment at all breaches.auto_remediate (SELECT: "false", "true"; default "false"): must be "true" for the annotation to even be considered.dry_run (SELECT: "true", "false"; default "true"): must be explicitly "false", together with auto_remediate: "true", for the annotation to actually be written.outputs.list_deployments.uri: the raw DeploymentList JSON for every run.outputs.evaluate_secret_exposure.vars.flagged_count / .flagged_deployments: the computed exposure result for every run.k8s_namespace at a namespace with at least one Deployment whose container defines env: [{name: ..., valueFrom: {secretKeyRef: {...}}}].K8S_MASTER_URL, K8S_TOKEN, and SLACK_WEBHOOK_URL secrets.auto_remediate: "false") and confirm alert_secret_exposure_risk fires naming the expected Deployment(s), and that log_remediation_disabled logs without annotating anything.auto_remediate: "true" and dry_run: "true" and confirm log_dry_run_remediation describes the annotation without applying it.auto_remediate: "true" and dry_run: "false" and confirm with kubectl get deployment <name> -o jsonpath='{.metadata.annotations}' that the flagged Deployment now carries security.kestra.io/secret-env-exposure: flagged.periodic_exposure_check once you trust the check; it always runs in the safe auto_remediate: "false" / dry_run: "true" mode regardless of what you leave the flow's own defaults set to.evaluate_secret_exposure to also check envFrom[].secretRef (bulk Secret-to-env injection), which this audit does not currently walk.k8s_namespace values with io.kestra.plugin.core.flow.Loop (ForEach is deprecated) to sweep an entire cluster in one run.Pause approval task instead of (or alongside) the static auto_remediate input, for a human-reviewed flag on production namespaces.env to a mounted volume - this blueprint deliberately only flags for review rather than attempting that structural edit automatically.kubectl.Get's FETCH/FETCH_ONE modes do not carry the pod template spec. Verified from the plugin's own documented output schema: those modes return only metadataItems/statusItems (and metadataItem/statusItem). This blueprint uses fetchType: STORE specifically because it is the only mode that returns the full raw object JSON, including spec.template.spec.containers[].env.outputFiles is not a documented property of kubectl.Get. An existing in-repo flow sets it alongside fetchType: STORE without apparently using it; this blueprint omits it and reads the stored result through the documented uri output instead, to avoid relying on an unverified property.metadata.annotations. Verified against this plugin's own documented Secret-rotation example, which also uses patchStrategy: JSON_MERGE; a JSON_MERGE patch replaces only the keys present in the patch body, so spec.template, every container's env, and the referenced Secret are all left exactly as they were.docker run command above starts a k3s server, but the exact K8S_MASTER_URL/K8S_TOKEN values must be pulled from that cluster's own generated kubeconfig, which was not done here; treat the command as a starting point, not a proven one-liner.auto_remediate and dry_run are independent gates, not a single boolean. Both must be "true"/"false" respectively for the annotation Loop to run; setting only one leaves the other still blocking.Switch case keys "true"/"false" are quoted strings. auto_remediate renders as the literal string "true" or "false"; unquoted true:/false: YAML map keys would parse as booleans instead and would not match.