Skip to content

CDK Host Pentest

CDK Host Pentest #96

name: CDK Host Pentest
# Runs the CDK (cdk-team/CDK) container-penetration toolkit inside the
# klangk-HOST image — the privileged control plane (rootless-podman-in-
# container that runs the backend + the proxy + nested workspace containers).
#
# Unlike the workspace pentest, the host is intentionally privileged
# (--cap-add SYS_ADMIN, seccomp=unconfined, systempaths=unconfined,
# /dev/fuse, /dev/net/tun — see scripts/run-host-container.sh) because
# it has to manage nested containers. So CDK WILL find escape primitives
# here (SYS_ADMIN chief among them). The gate therefore diffs CDK's
# Critical findings against a BASELINE ALLOWLIST of known-expected
# primitives (currently: SYS_ADMIN) and fails only on a NEW/UNEXPECTED
# one — an accidental extra --cap-add, an unintended --privileged (which
# would let mount-cgroup actually escape), a mounted runtime socket, etc.
# `fail-on-escape` defaults ON so the daily run alarms on regressions.
#
# Threat model: this is the nested-escape path. A workspace container
# that breaks out to the host image still has to escape the host image
# to reach the real host. Auditing the host's posture catches any
# capability that shouldn't be there (an accidental extra --cap-add,
# an unintended --privileged, a writable runtime socket mounted in).
#
# `cdk evaluate --full` runs recon. `run-exploits` (default on for the
# host — findings are expected, so we want the full picture) fires the
# SYS_ADMIN-relevant escape-class exploits.
#
# CDK release: https://github.com/cdk-team/CDK/releases
on:
schedule:
# Daily 06:43 UTC
- cron: "43 6 * * *"
workflow_dispatch:
inputs:
cdk-version:
description: "CDK release tag to use"
required: false
default: "v1.5.6"
type: string
fail-on-escape:
description: "Fail on a NEW (non-baseline) Critical finding. The gate allowlists SYS_ADMIN, so this has signal. ON by default."
required: false
default: true
type: boolean
run-exploits:
description: "Fire escape-class exploits (host findings expected; ON by default)"
required: false
default: true
type: boolean
permissions:
contents: read
# `workflow_dispatch` input defaults do NOT apply on the `schedule`
# trigger — the `inputs` context is empty on cron. Without coalescing
# here, the daily run got CDK_VERSION="" (404 on the download), the
# gate was silently skipped (so the daily run did NOT alarm on
# regressions despite `fail-on-escape` defaulting on), and run-exploits
# was skipped despite defaulting on for the host. These defaults mirror
# the `workflow_dispatch` input defaults below.
# See https://github.com/mcdonc/klangk/actions/runs/29010504270
env:
CDK_VERSION: ${{ inputs.cdk-version || 'v1.5.6' }}
FAIL_ON_ESCAPE: ${{ inputs.fail-on-escape }}
RUN_EXPLOITS: ${{ inputs.run-exploits }}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
pentest:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- name: Bootstrap devenv
uses: ./.github/actions/devenv-setup
with:
# build-host-image (a devenv *script*, not a task) handles all
# its own prerequisites: flutter web + workspace image. So we
# only bootstrap devenv here, then build the host in the next
# step via `devenv shell -- build-host-image`.
build-tasks: ""
- name: Build host image
shell: bash
run: |
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
devenv shell -- build-host-image
- name: Download CDK binary
shell: bash
run: |
set -euo pipefail
VER="${{ env.CDK_VERSION }}"
ARCH="cdk_linux_amd64"
URL="https://github.com/cdk-team/CDK/releases/download/${VER}/${ARCH}"
echo "Downloading $URL"
curl -fL "$URL" -o cdk
chmod +x cdk
# Expected sha256 for v1.5.6; warn-only if version pinned elsewhere.
EXPECTED="c6a222d823fb49a82381dbbcecbc57cd6f9d03c4222ec8c280289b6627beceb9"
GOT="$(sha256sum cdk | cut -d' ' -f1)"
echo "sha256: $GOT"
if [ "$VER" = "v1.5.6" ] && [ "$GOT" != "$EXPECTED" ]; then
echo "::warning::CDK sha256 mismatch (expected $EXPECTED)"; exit 1
fi
./cdk version || ./cdk --version || true
- name: Launch host container with real posture
shell: bash
run: |
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
set -euo pipefail
IMAGE="${KLANGK_HOST_IMAGE:-klangk-host}:latest"
mkdir -p out
# Reproduce scripts/run-host-container.sh posture exactly.
# --privileged is intentionally NOT used here (run-host-container
# doesn't either) — SYS_ADMIN + seccomp=unconfined is the real
# shipped posture. CDK runs as root to see the true cap surface.
# Device flags are conditional (runner images may not expose them).
DEVS=()
[ -e /dev/fuse ] && DEVS+=(--device /dev/fuse)
[ -e /dev/net/tun ] && DEVS+=(--device /dev/net/tun)
devenv shell -- docker run -d --name cdk-host \
--cap-add SYS_ADMIN \
"${DEVS[@]}" \
--security-opt seccomp=unconfined \
--security-opt systempaths=unconfined \
-v "$PWD/cdk:/tmp/cdk:ro" \
--user root \
--entrypoint /bin/sh \
"$IMAGE" -c "sleep infinity"
devenv shell -- docker exec cdk-host /tmp/cdk version
- name: CDK evaluate (recon)
shell: bash
run: |
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
devenv shell -- docker exec cdk-host /tmp/cdk evaluate --full \
> out/evaluate.txt 2>&1
cat out/evaluate.txt
- name: CDK escape exploits (default on for host)
if: ${{ env.RUN_EXPLOITS != 'false' }}
shell: bash
run: |
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
# SYS_ADMIN-relevant escape exploits for the host posture.
# `id` payload: a successful escape prints host uid instead
# of failing. Findings are EXPECTED here (host is privileged);
# this is for baseline/triage, not gating.
: > out/exploits.txt
run_exploits=(
"docker-sock-check /var/run/docker.sock"
"check-ptrace"
"abuse-unpriv-userns id"
"cap-dac-read-search"
"mount-procfs /mnt/host_proc id"
"mount-disk"
"mount-cgroup id"
"rewrite-cgroup-devices"
)
for entry in "${run_exploits[@]}"; do
# shellcheck disable=SC2086 # intentional word-split on args
set -- $entry
exp="$1"; shift
{
echo "=== cdk run $exp $* ==="
devenv shell -- docker exec cdk-host /tmp/cdk run "$exp" "$@" 2>&1 \
|| echo "($exp exited non-zero)"
} >> out/exploits.txt
done
cat out/exploits.txt
- name: Capture container posture (inspect)
if: always()
shell: bash
run: |
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
devenv shell -- docker inspect cdk-host \
> out/container-inspect.json 2>&1 || true
devenv shell -- docker exec cdk-host /tmp/cdk evaluate \
> out/evaluate-short.txt 2>&1 || true
- name: Diff Critical findings against baseline
if: always()
shell: bash
run: |
# CDK prefixes concrete escape primitives with "Critical - ". The
# ALLOWLIST below names Critical findings that are part of this
# posture BY DESIGN and expected on every run. The gate fails only
# on Criticals NOT matched here — i.e. new/unexpected primitives.
# Add a substring ONLY when a finding is intentionally accepted.
ALLOW_PATTERNS=(
"SYS_ADMIN Capability Found"
)
: > out/all-criticals.txt
: > out/new-criticals.txt
grep -E '^Critical' out/evaluate.txt >> out/all-criticals.txt 2>/dev/null || true
cp out/all-criticals.txt out/new-criticals.txt
for pat in "${ALLOW_PATTERNS[@]}"; do
{ grep -vF "$pat" out/new-criticals.txt || true; } > out/new.tmp
mv out/new.tmp out/new-criticals.txt
done
echo "All Critical findings: $(wc -l < out/all-criticals.txt)"
echo "New/unexpected (not baseline-allowed): $(wc -l < out/new-criticals.txt)"
- name: Gate on Critical findings (opt-in)
if: ${{ env.FAIL_ON_ESCAPE != 'false' }}
shell: bash
run: |
if [ -s out/new-criticals.txt ]; then
echo "::error::New/unexpected CDK Critical finding(s) — not in the baseline allowlist:"
cat out/new-criticals.txt
exit 1
fi
echo "No new Critical escape primitives (baseline allowlist satisfied)."
- name: Build summary
if: always()
shell: bash
run: |
{
echo "### 💣 CDK host pentest"
echo
echo "CDK \`${{ env.CDK_VERSION }}\` run inside \`${KLANGK_HOST_IMAGE:-klangk-host}:latest\`"
echo "as root, launched with the real host posture (--cap-add SYS_ADMIN,"
echo "seccomp=unconfined, systempaths=unconfined, /dev/fuse, /dev/net/tun)."
echo
echo "**The host is intentionally privileged** (it manages nested workspace"
echo "containers). CDK is expected to report escape primitives here — this"
echo "run records the baseline; review for anything *new or unexpected*."
echo
ALL=$(wc -l < out/all-criticals.txt 2>/dev/null || echo 0)
NEW=$(wc -l < out/new-criticals.txt 2>/dev/null || echo 0)
ALLOWED=$((ALL - NEW))
echo "Criticals: **$ALL total** ($ALLOWED baseline-allowed, **$NEW new/unexpected**)."
echo '```'
cat out/all-criticals.txt 2>/dev/null || echo "(none)"
echo '```'
echo
echo "<details><summary>CDK <code>evaluate --full</code> output</summary>"
echo
echo '```'
cat out/evaluate.txt 2>/dev/null
echo '```'
echo "</details>"
if [ "${{ env.RUN_EXPLOITS }}" = "true" ]; then
echo "<details><summary>CDK escape-exploit output</summary>"
echo
echo '```'
cat out/exploits.txt 2>/dev/null
echo '```'
echo "</details>"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload CDK artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: cdk-host-pentest
path: out/
if-no-files-found: warn
retention-days: 30
- name: Cleanup
if: always()
shell: bash
run: |
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
devenv shell -- docker rm -f cdk-host >/dev/null 2>&1 || true