Repository navigation
CDK Host Pentest #96
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |