sklp — security audit (embedded runtime + skt)

How the isolation surface of `sklp run -oci` (the embedded containerd + runc + slirp4netns runtime) and `skt` (the rootless toolchain manager) was reviewed for container escape, host-secret exposure, network egress, and supply-chain tampering — what was found, what was fixed, and what was deliberately ruled out.

sklp — Security Audit

A code-level security review of the two components that carry sklp's real attack surface:

This page documents a point-in-time review. The findings below carry a status (fixed / open) reflecting the state after the fixes that shipped alongside the audit. Re-run the method (below) after material changes to the runtime spec, the mount set, or the download/extract paths.


Method

The review was a code audit, not a live-VM scan or network pentest. No production host was touched; the conclusions come from reading the actual source and the pinned dependency behaviour (containerd v2.3.1, tar 0.4.46, runc 1.4.2, slirp4netns 1.3.4).

It ran as a multi-agent pipeline with an adversarial verification stage, so that a finding only survives if a second, skeptical pass can confirm it against the real code:

Recon ──▶ Audit (5 parallel axes) ──▶ Verify (adversarial, per finding)
  1. Recon — one agent maps the threat-relevant structure: how the OCI spec is built, which mounts are injected, how the userns/uid mapping works, what capabilities runc is given, how slirp wires egress, how registry auth flows, how the embedded binaries are extracted and exec'd, and (for skt) how downloads are verified and archives extracted. This map feeds every downstream auditor.

  2. Audit — five agents run in parallel, one per vulnerability class:

    • container escape / privilege — caps, seccomp, host namespaces, /proc exposure, noNewPrivileges, rootless vs rootful.
    • mounts / filesystem — host bind-mounts of secrets, tar/zip-slip, .env handling, workspace write surface, world-readable artifacts.
    • embedded supply-chain — integrity of the go:embed-ded runc/slirp4netns binaries at extract and exec time.
    • network / credentials — registry creds in argv/env/logs, TLS, slirp egress, metadata/SSRF reachability.
    • skt supply-chain — checksum verification, TLS, archive extraction, exec of downloaded binaries, cache poisoning.
  3. Verify — every raw finding is handed to an independent agent prompted to refute it: re-read the cited file:line, check whether the dangerous mount/cap/exec is actually present or guarded elsewhere, and — critically — whether the rootless user-namespace model neutralizes the claimed impact. A finding defaults to refuted unless the code proves it. Severity is adjusted down when impact is overstated.

The review produced 32 raw findings → 22 confirmed, 3 refuted (the rest folded into duplicates or positive-control notes).

Threat model

The runtime is rootless by design: the OCI spec maps container uid 0 to the invoking operator's own unprivileged host uid (os.Getuid()), uid 1..65536 to a subuid range, drops CAP_SYS_ADMIN, and sets noNewPrivileges. The package's own comment states the userns remap is "the only thing protecting the host."

This single fact reshapes the whole result: a malicious or compromised pipeline step cannot escape to host root, because there is no host root in the mapping. Every "escape"-flavoured finding therefore additionally requires a separate kernel CVE — they are defense-in-depth gaps, not self-contained escapes. The findings that matter are the ones the userns does not cover:

Out of scope

No live-VM hardening, no SSH/fail2ban/firewall audit, no Kubernetes cluster review, no dependency CVE sweep. This page is about the isolation boundary in the code, not the deployment around it.


Results summary

SeverityConfirmedOf which fixed
🔴 Critical / high → adjustedsecrets-on-disk class✅ fixed
🟠 Mediumegress, skt symlink, skt httpegress fixed
🟡 Lowhardening (seccomp, masks, caps, integrity)open
⚪ Inforootless residualsn/a

Bottom line: host isolation holds (rootless is the right call). The two real priorities were secrets written world-readable on disk and unfiltered slirp egress to the cloud metadata endpoint — both fixed. The remainder is defense-in-depth hardening.


Findings

🔴 Secrets written to disk (class) — FIXED

The highest-impact finding, confirmed against a live artifact on the developer's own machine during the review.

config.json secrets — runtime/spec.go · fixed in #611, #612 The OCI bundle config.json embedded every pipeline secret in process.env (GITHUB_TOKEN, provider API keys, the full contents of <repo>/.env) and was written mode 0644 (world-readable) under a 0755 cache dir, then never deleted after the run. A live run-ci/config.json was found containing a real ghp_… token and several API keys.

Service logs — runtime/container.go · fixed in #612 A re-scan after #611 found the same class on a path the first fix did not touch: sidecar stdout/stderr was captured via os.Create (0644) at cacheRoot/svc-<name>.log and never cleaned up. A service that echoes its config at boot (e.g. a connection string with POSTGRES_PASSWORD) leaked svc.Env onto disk.

The runc state.json was empirically verified not to persist process.env (a GITHUB_TOKEN canary was absent), is written 0600, and is cleaned by delete --force — so it is not a residual leak.

🟠 Network egress to cloud metadata — FIXED

runtime/slirp.go · fixed in #610 slirp4netns ran with only --disable-host-loopback, which blocks the host loopback only. It does not block the link-local metadata address. The container's NAT egress (tap0 default route) could therefore reach 169.254.169.254 (cloud IMDS → IAM credentials) and any host-LAN service. Rootless does not neutralize this: slirp NAT rides the host routing table independent of container privilege — a textbook SSRF-to-metadata path on a cloud CI host.

apps/skt/src/engine/extract.rs:90 · medium untar_gz rejects only entry paths containing .. — it does not inspect symlink/hardlink targets. tar 0.4.46's Entry::unpack performs no containment check (its own rustdoc says so). A checksum-passing archive with symlink evil -> /home/user/.config then evil/file writes outside the cache slot — overwriting ~/.bashrc, skt recipes, or ~/.docker/config.json.

🟠 skt — no https allowlist — OPEN

apps/skt/src/engine/http.rs:23 · medium fetch_string/fetch_reader pass the URL straight to ureq with no scheme check. A recipe can set download-url/checksum-url to http://; a MITM then rewrites both payload and checksum (same channel) and the trojaned binary passes verification and is exec'd. All embedded recipes are https, but nothing enforces it.

🟡 Container hardening (low — bounded by rootless) — OPEN

These widen the kernel/host surface but are gated by the rootless userns; each additionally needs a separate kernel bug to matter.

🟡 Embedded-binary & override hardening (low) — OPEN

🟡 skt supply-chain residuals (low) — OPEN


Refuted findings

Three findings were dismissed by the adversarial pass — recorded so they are not re-raised:


Positive controls (verified good)

What correctly mitigates entire classes of risk — confirmed, so reviewers don't flag them as gaps:


Reproducing the review

Re-run after changes to the OCI spec, the mount set, the slirp flags, or the skt download/extract path. The method is model-agnostic: a recon pass to map the surface, parallel auditors per vulnerability class, and an adversarial verify pass that defaults to refuted and re-weighs every impact against the rootless userns model. The discipline that makes it trustworthy is the verify stage — a finding that cannot be proven against the real file:line does not ship.