id: kube-bench-cis-benchmark-audit
namespace: company.team
description: |
Audit a Kubernetes cluster against the CIS Kubernetes Benchmark with
kube-bench, from a weekly schedule or an on-demand webhook. The scan runs
in its official container with a bundled fixture report, so the flow stays
deterministic when no cluster is reachable, the JSON is parsed into a
failed-check count and the failing control ids, and anything above the
allowed threshold opens a GitHub issue, alerts Slack, and appends
remediation notes per failed control group before the verdict, counts, and
report location are published as outputs.
triggers:
- id: weekly_cis_audit
type: io.kestra.plugin.core.trigger.Schedule
description: Primary trigger, a weekly CIS benchmark audit every Monday at 06:00
UTC; shipped disabled until an operator switches it on.
cron: "0 6 * * 1"
disabled: true
- id: on_demand_audit
type: io.kestra.plugin.core.trigger.Webhook
description: POST here to audit a cluster on demand, for example after a control
plane upgrade or before an external review.
key: "{{ secret('KUBEBENCH_WEBHOOK_KEY') }}"
inputs:
- id: cluster_name
type: STRING
defaults: staging-eks-cluster
description: Logical name of the cluster under audit, used in the remediation
report, the GitHub issue title, and the Slack message.
- id: failed_threshold
type: INT
defaults: 0
description: Maximum number of failed CIS checks tolerated before the run
escalates to a GitHub issue and to Slack.
- id: github_repository
type: STRING
defaults: your-org/cluster-baselines
description: Repository in owner/repo form that receives the remediation issue
when the threshold is exceeded.
tasks:
# --- 1. benchmark scan and parse ---
- id: audit_workspace
type: io.kestra.plugin.core.flow.WorkingDirectory
description: One shared directory so the scan, the parser, and the remediation
report all see the same files.
tasks:
- id: run_kube_bench
type: io.kestra.plugin.scripts.shell.Commands
description: Write a deterministic kube-bench fixture, run kube-bench in its
official container, and fall back to the fixture when no cluster is
reachable so the audit is reproducible offline.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
entryPoint: []
containerImage: aquasec/kube-bench:latest
env:
CLUSTER_NAME: "{{ inputs.cluster_name }}"
outputFiles:
- kube-bench-report.json
commands:
- |
set -u
cat > mock-report.json <<'JSON'
{
"Controls": [{
"id": "4",
"text": "Kubernetes Policies",
"node_type": "policies",
"tests": [{
"text": "RBAC and Service Accounts",
"section": "4.1",
"pass": 7, "fail": 2, "warn": 1,
"results": [
{
"test_number": "4.1.1", "test_section": "4.1", "status": "FAIL",
"test_desc": "4.1.1 Ensure cluster-admin privileges are used only where required",
"remediation": "Audit ClusterRoleBindings and drop cluster-admin from service accounts that do not need it."
},
{
"test_number": "4.1.4", "test_section": "4.1", "status": "PASS",
"test_desc": "4.1.4 Minimize access to secrets",
"remediation": ""
},
{
"test_number": "4.1.5", "test_section": "4.1", "status": "FAIL",
"test_desc": "4.1.5 Ensure default service accounts are not actively used",
"remediation": "Set automountServiceAccountToken to false for the default service account in every namespace."
},
{
"test_number": "4.2.3", "test_section": "4.2", "status": "WARN",
"test_desc": "4.2.3 Minimize the admission of containers with allowPrivilegeEscalation",
"remediation": "Set securityContext.allowPrivilegeEscalation to false in every pod security context."
}
]
}]
}]
}
JSON
echo "Scanning cluster ${CLUSTER_NAME} with kube-bench"
kube-bench --json-output > kube-bench-report.json || true
if [ ! -s kube-bench-report.json ]; then
cp mock-report.json kube-bench-report.json
echo "no cluster reachable from this worker, emitting the bundled fixture report"
fi
echo "report bytes $(wc -c < kube-bench-report.json)"
- id: parse_report
type: io.kestra.plugin.scripts.python.Script
description: Read the kube-bench JSON, count total and failed checks, collect
the failing control ids and the per-group remediation, and emit the
verdict through the outputs protocol.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.12-slim
env:
CLUSTER_NAME: "{{ inputs.cluster_name }}"
FAILED_THRESHOLD: "{{ inputs.failed_threshold }}"
script: |
import json
import os
report_path = "kube-bench-report.json"
cluster = os.environ.get("CLUSTER_NAME", "unknown-cluster")
try:
threshold = int(os.environ.get("FAILED_THRESHOLD", "0"))
except ValueError:
threshold = 0
data = {}
if os.path.exists(report_path) and os.path.getsize(report_path) > 0:
try:
with open(report_path, "r") as handle:
data = json.load(handle)
except ValueError:
data = {}
results = []
for control in data.get("Controls") or []:
if not isinstance(control, dict):
continue
for group in control.get("tests") or []:
if not isinstance(group, dict):
continue
section = str(group.get("section") or "unknown")
title = str(group.get("text") or section)
for entry in group.get("results") or []:
if not isinstance(entry, dict):
continue
row = dict(entry)
row.setdefault("test_section", section)
row.setdefault("test_number", section)
row["section_title"] = title
results.append(row)
failed = [r for r in results if str(r.get("status", "")).strip().upper() == "FAIL"]
critical_controls = sorted({str(r.get("test_number") or "unknown") for r in failed})
controls_summary = ", ".join(critical_controls) if critical_controls else "none"
buckets = {}
for row in failed:
key = str(row.get("test_section") or "unknown")
bucket = buckets.setdefault(key, {"id": key, "title": row.get("section_title", key), "failed": 0, "notes": []})
bucket["failed"] += 1
note = str(row.get("remediation") or "Review the control and align the cluster configuration with the CIS benchmark.")
if note not in bucket["notes"]:
bucket["notes"].append(note)
failed_groups = []
for key in sorted(buckets):
bucket = buckets[key]
failed_groups.append({
"id": bucket["id"], "title": bucket["title"], "failed": bucket["failed"],
"remediation": " ".join(bucket["notes"]),
})
verdict = "PASS" if len(failed) <= threshold else "FAIL"
with open("remediation-report.md", "w") as handle:
handle.write("# CIS Kubernetes Benchmark remediation notes\n\n")
handle.write("Cluster: " + cluster + "\n\n")
if not failed_groups:
handle.write("No failed checks, nothing to remediate.\n")
payload = {
"total_checks": len(results),
"failed_count": len(failed),
"critical_controls": critical_controls,
"controls_summary": controls_summary,
"failed_groups": failed_groups,
"verdict": verdict,
}
print("cluster=%s total=%d failed=%d verdict=%s" % (cluster, len(results), len(failed), verdict))
print("::" + json.dumps({"outputs": payload}) + "::")
# --- 2. remediation notes ---
- id: append_remediation
type: io.kestra.plugin.core.flow.Loop
description: Walk the failed control groups one at a time and append a
remediation section for each into the shared report file.
values: "{{ outputs.parse_report.vars.failed_groups }}"
concurrencyLimit: 1
tasks:
- id: append_control_note
type: io.kestra.plugin.scripts.python.Script
description: Append the control id, group title, and remediation text for the
current failed control group.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.12-slim
env:
CONTROL_ID: "{{ item.id }}"
CONTROL_TITLE: "{{ item.title }}"
REMEDIATION: "{{ item.remediation }}"
script: |
import json
import os
control_id = os.environ.get("CONTROL_ID", "unknown")
title = os.environ.get("CONTROL_TITLE", "")
remediation = os.environ.get("REMEDIATION", "")
with open("remediation-report.md", "a") as handle:
handle.write("## " + control_id + " " + title + "\n\n")
handle.write(remediation.strip() + "\n\n")
print("appended remediation for " + control_id)
print("::" + json.dumps({"outputs": {"appended": control_id}}) + "::")
# --- 3. verdict gate ---
- id: check_verdict
type: io.kestra.plugin.core.flow.If
description: Escalate only when the failed check count sits above the tolerated
threshold, a compliant cluster is recorded and left alone.
condition: "{{ (outputs.parse_report.vars.failed_count | number) >
(inputs.failed_threshold | number) }}"
then:
- id: open_remediation_issue
type: io.kestra.plugin.github.issues.Create
description: Open a labeled GitHub issue listing the failing control ids so
remediation is tracked where the team already works.
jwtToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.github_repository }}"
title: "CIS benchmark failures on {{ inputs.cluster_name }}"
body: |
## CIS Kubernetes Benchmark audit
Cluster **{{ inputs.cluster_name }}** failed **{{ outputs.parse_report.vars.failed_count }}** of **{{ outputs.parse_report.vars.total_checks }}** checks (threshold {{ inputs.failed_threshold }}).
Failing control ids:
{{ outputs.parse_report.vars.controls_summary }}
Execution {{ execution.id }}
Work the remediation notes attached to the execution, then close this issue.
labels:
- kubernetes
- cis-benchmark
- id: alert_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Tell the platform channel how many checks failed, which controls
broke, and that a GitHub issue is waiting.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "CIS benchmark audit FAILED for {{ inputs.cluster_name }} - {{ outputs.parse_report.vars.failed_count }} of {{ outputs.parse_report.vars.total_checks }} checks failed. Controls {{ outputs.parse_report.vars.controls_summary }}. Issue opened in {{ inputs.github_repository }}. Execution {{ execution.id }}."
}
else:
- id: log_compliant
type: io.kestra.plugin.core.log.Log
description: Record the compliant result so the execution history doubles as
audit evidence.
message: "CIS benchmark audit passed for {{ inputs.cluster_name }} with {{
outputs.parse_report.vars.failed_count }} failed check(s)."
errors:
- id: notify_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Escalate any failure to the ops channel with the execution url.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text": "FAILED: {{ flow.id }} {{ execution.id }}"}
outputs:
- id: verdict
type: STRING
description: PASS when the failed check count is within the threshold, otherwise FAIL.
value: "{{ outputs.parse_report.vars.verdict }}"
- id: failed_count
type: INT
description: Number of kube-bench checks whose status is FAIL, for example 2.
value: "{{ outputs.parse_report.vars.failed_count }}"
- id: critical_controls
type: ARRAY
description: 'Distinct failing CIS control ids, for example ["4.1.1", "4.1.5"].'
value: "{{ outputs.parse_report.vars.critical_controls | toJson }}"
- id: report_uri
type: STRING
description: Internal storage URI of the kube-bench JSON report produced by this run.
value: "{{ outputs.run_kube_bench.outputFiles['kube-bench-report.json'] }}"