From 8955ba6873f455178af525a1467d720b0c0ab744 Mon Sep 17 00:00:00 2001 From: swaphb Date: Sun, 9 Aug 2026 21:46:33 -0400 Subject: [PATCH] Add CI --- .github/workflows/kube-bench-scan.yml | 77 +++++++++++++++++++++++++++ README.md | 2 +- ci/README.md | 58 ++++++++++++++++++++ ci/kube-bench-k3s-job.yaml | 65 ++++++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/kube-bench-scan.yml create mode 100644 ci/README.md create mode 100644 ci/kube-bench-k3s-job.yaml diff --git a/.github/workflows/kube-bench-scan.yml b/.github/workflows/kube-bench-scan.yml new file mode 100644 index 0000000..600d6fe --- /dev/null +++ b/.github/workflows/kube-bench-scan.yml @@ -0,0 +1,77 @@ +name: Kube-bench CIS scan + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + kube-bench: + name: Scan ephemeral K3s cluster + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install k3d + run: curl -sfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + + - name: Create ephemeral K3s cluster + run: | + k3d cluster create --config local-quickstart/k3d-cluster-config.yaml + kubectl wait --for=condition=Ready nodes --all --timeout=180s + kubectl get nodes -o wide + + - name: Run kube-bench + id: scan + shell: bash + run: | + set +e + mkdir -p artifacts + kubectl create namespace kube-bench --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -f ci/kube-bench-k3s-job.yaml + kubectl wait --for=condition=complete job/kube-bench -n kube-bench --timeout=180s + job_rc=$? + kubectl logs -n kube-bench job/kube-bench > artifacts/kube-bench-k3s-cis-1.7.json || true + + if ! jq -e . artifacts/kube-bench-k3s-cis-1.7.json >/dev/null; then + echo "kube-bench did not produce valid JSON" >&2 + cat artifacts/kube-bench-k3s-cis-1.7.json + exit 1 + fi + + pass_count=$(jq '[.. | objects | select(has("total_pass")) | .total_pass] | add // 0' artifacts/kube-bench-k3s-cis-1.7.json) + warn_count=$(jq '[.. | objects | select(has("total_warn")) | .total_warn] | add // 0' artifacts/kube-bench-k3s-cis-1.7.json) + fail_count=$(jq '[.. | objects | select(has("total_fail")) | .total_fail] | add // 0' artifacts/kube-bench-k3s-cis-1.7.json) + + { + echo "## kube-bench CIS K3s 1.7" + echo + echo "| Result | Count |" + echo "| --- | ---: |" + echo "| Pass | ${pass_count} |" + echo "| Warn | ${warn_count} |" + echo "| Fail | ${fail_count} |" + echo + echo "Findings are reported as an artifact. The disposable K3d baseline is not a merge gate yet." + } >> "$GITHUB_STEP_SUMMARY" + + exit "$job_rc" + + - name: Upload kube-bench report + if: always() + uses: actions/upload-artifact@v4 + with: + name: kube-bench-k3s-cis-1.7 + path: artifacts/kube-bench-k3s-cis-1.7.json + if-no-files-found: warn + + - name: Delete ephemeral cluster + if: always() + run: k3d cluster delete security-baseline + diff --git a/README.md b/README.md index fbb16f7..e1b52a0 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ It is not needed for the portfolio demo and is not run in CI. - [x] Phase 1: local k3d definition and optional Terraform/Ansible path - [ ] Phase 2: ArgoCD app-of-apps deployment (scaffolded; runtime verification pending) -- [ ] Phase 3: kube-bench CI scan +- [x] Phase 3: kube-bench CI scan - [ ] Phase 4: Kyverno policy set and CIS mapping - [ ] Phase 5: Falco rules and webhook alerting - [ ] Phase 6: test workloads and evidence capture diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..89dfd7e --- /dev/null +++ b/ci/README.md @@ -0,0 +1,58 @@ +# CIS benchmark scanning + +The scan uses the official kube-bench K3s profile, `k3s-cis-1.7`, against a +disposable K3d control-plane node. kube-bench needs host PID access and +read-only host mounts because the CIS checks inspect processes, permissions, +and node configuration. The job is intentionally separate from the ArgoCD +application tree so the scanner cannot become part of the workload baseline. + +GitHub Actions creates the local cluster, runs the job, stores the JSON report, +and prints pass, warn, and fail totals in the workflow summary. The workflow +fails if the scanner job or report generation fails. It does not yet fail on +CIS findings because a vanilla K3d cluster is expected to produce findings; +later hardening phases can turn selected controls into merge gates. + +To run the same scan locally against the active K3d cluster: + +```bash +kubectl create namespace kube-bench --dry-run=client -o yaml | kubectl apply -f - +kubectl delete job kube-bench -n kube-bench --ignore-not-found +kubectl apply -f ci/kube-bench-k3s-job.yaml +kubectl wait --for=condition=complete job/kube-bench -n kube-bench --timeout=180s +kubectl logs -n kube-bench job/kube-bench > kube-bench-report.json +``` + +The benchmark release is pinned in the Job image. Update it deliberately when +the benchmark support matrix or K3s version changes. + +## Gitea Actions support + +Gitea Actions can run the same workflow file. Gitea's default workflow search +order includes `.gitea/workflows` and `.github/workflows`; because this +repository has no `.gitea/workflows` directory, Gitea will discover the +existing `.github/workflows/kube-bench-scan.yml` file. GitHub Actions continues +to use that same file. + +The Gitea instance must have Actions enabled and an `act_runner` registered at +the repository, organization, or instance level. The runner needs Docker +access because the workflow creates a k3d cluster and runs the kube-bench Job. +Register a dedicated runner for this repository rather than sharing a runner +with unrelated repositories. + +Example Docker runner setup, with the registration token supplied separately: + +```bash +docker run -d \ + --name kubernetes-security-baseline-runner \ + -e GITEA_INSTANCE_URL=https://git.swaphb.com/ \ + -e GITEA_RUNNER_REGISTRATION_TOKEN="$GITEA_RUNNER_REGISTRATION_TOKEN" \ + -e GITEA_RUNNER_NAME=kubernetes-security-baseline \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v gitea-runner-data:/data \ + docker.io/gitea/act_runner:latest +``` + +Mounting the Docker socket gives workflow jobs substantial control over the +runner host. Use a dedicated disposable runner, restrict its repository scope, +and avoid placing unrelated credentials on that host. + diff --git a/ci/kube-bench-k3s-job.yaml b/ci/kube-bench-k3s-job.yaml new file mode 100644 index 0000000..c0ea8fc --- /dev/null +++ b/ci/kube-bench-k3s-job.yaml @@ -0,0 +1,65 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: kube-bench + namespace: kube-bench + labels: + app.kubernetes.io/name: kube-bench + app.kubernetes.io/part-of: kubernetes-security-baseline +spec: + backoffLimit: 0 + template: + metadata: + labels: + app.kubernetes.io/name: kube-bench + spec: + # The K3s CIS profile checks host processes and node configuration. + hostPID: true + nodeSelector: + node-role.kubernetes.io/control-plane: "true" + tolerations: + - operator: Exists + restartPolicy: Never + containers: + - name: kube-bench + image: docker.io/aquasec/kube-bench:v0.16.0 + command: + - kube-bench + - run + - --benchmark + - k3s-cis-1.7 + - --json + securityContext: + privileged: true + volumeMounts: + - name: etc-rancher-k3s + mountPath: /etc/rancher/k3s + readOnly: true + - name: var-lib-rancher-k3s + mountPath: /var/lib/rancher/k3s + readOnly: true + - name: var-lib-kubelet + mountPath: /var/lib/kubelet + readOnly: true + - name: var-lib-cni + mountPath: /var/lib/cni + readOnly: true + - name: etc-cni-netd + mountPath: /etc/cni/net.d + readOnly: true + volumes: + - name: etc-rancher-k3s + hostPath: + path: /etc/rancher/k3s + - name: var-lib-rancher-k3s + hostPath: + path: /var/lib/rancher/k3s + - name: var-lib-kubelet + hostPath: + path: /var/lib/kubelet + - name: var-lib-cni + hostPath: + path: /var/lib/cni + - name: etc-cni-netd + hostPath: + path: /etc/cni/net.d