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:
runtime/— the embedded, daemonless OCI runtime (containerd + runc + slirp4netns, in-process) behind theembedded_runtimebuild tag. This is where container escape, host bind-mounts, network egress, and privilege handling live.apps/skt/src/— the rootless toolchain manager (Rust) that downloads, checksum-verifies, extracts, and execs toolchains (node, go, rust, python, pnpm).
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)
-
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.
-
Audit — five agents run in parallel, one per vulnerability class:
- container escape / privilege — caps, seccomp, host namespaces,
/procexposure,noNewPrivileges, rootless vs rootful. - mounts / filesystem — host bind-mounts of secrets, tar/zip-slip,
.envhandling, 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.
- container escape / privilege — caps, seccomp, host namespaces,
-
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:
- host-side secret exposure (files written outside the container),
- network egress (slirp NAT rides the host routing table regardless of container privilege),
- supply-chain (what gets executed in the first place).
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
| Severity | Confirmed | Of which fixed |
|---|---|---|
| 🔴 Critical / high → adjusted | secrets-on-disk class | ✅ fixed |
| 🟠 Medium | egress, skt symlink, skt http | egress fixed |
| 🟡 Low | hardening (seccomp, masks, caps, integrity) | open |
| ⚪ Info | rootless residuals | n/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.
- The docker backend avoided this by passing secrets via
--env-file(argv); the embedded backend regressed it by persisting them. - Fix:
config.jsonwritten0600at all three writers, bundle dir0700, and the bundle (RemoveAll) removed in a teardown defer on both success and error paths. Guarded by a spec test so it cannot regress.
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.
- Fix: log moved inside the
0700bundle, written0600, removed for free by the existing bundle cleanup. Regression test asserts0600.
The
runcstate.jsonwas empirically verified not to persistprocess.env(aGITHUB_TOKENcanary was absent), is written0600, and is cleaned bydelete --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.
- Fix: blackhole
169.254.0.0/16+ private LAN ranges in the slirp netns.
🟠 skt — tar symlink escape (zip-slip) — OPEN
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.
- Gated behind a non-trivial precondition (upstream compromise or an attacker-controlled recipe origin); the 5 shipped recipes use trusted HTTPS origins.
- Suggested fix: reject entries whose link target is absolute or
contains
.., or usetar::Archive::unpack_in/ canonicalize the output underdest_dirbefore writing.
🟠 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.
- Suggested fix: enforce
url.starts_with("https://")in the HTTP layer (allow http only for loopback in tests).
🟡 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.
- No seccomp profile (
spec.go) — runc has no built-in default profile (Docker/containerd supply it); this backend bypasses them, so the full host syscall surface is unfiltered. Fix: embed the containerd/Docker default profile, assert it inspec_test.go. - No
maskedPaths/readonlyPaths—/proc/sched_debug,/proc/timer_list(not PID-namespaced) leak host task names/PIDs. The more dangerous paths (/proc/kcore,/proc/sysrq-trigger) are already blocked by the unprivileged userns. Fix: add the OCI default masks. - Over-broad capabilities —
CAP_DAC_OVERRIDE,CAP_MKNOD,CAP_NET_RAW,CAP_SETFCAPgranted to the runner and every third-party sidecar. Confined to the userns, but not minimal. Fix: trim to the needed set; reduced set for sidecars; growth-guard test. - Writable rootfs + per-digest shared cache — a run can plant a binary in the cached rootfs reused by a later same-digest run (same-user cache poisoning). Fix: per-run overlay / ephemeral upper.
- Image layers not re-verified against their digest before extraction
(containerd's bare fetcher is used, bypassing the verifying content
store); a partial/cached blob is reused on a
Stathit. Fix: hash each blob againstlayer.Digeston fetch and on cache-hit.
🟡 Embedded-binary & override hardening (low) — OPEN
- Embedded runc/slirp not re-verified at runtime against the pinned
hash (only at
go generate). Defends only against accidental embed corruption, not an active adversary who already controls the binary. SKLP_RUNC_PATHoverride accepted onos.Statexistence only, no hash check. Settable only by whoever already controls the operator's host env (who can already run code as that user) — no privilege boundary crossed. Fix: gate behind an explicit unsafe opt-in, or verify the override's sha256, and log loudly.
🟡 skt supply-chain residuals (low) — OPEN
- Checksum co-located with payload (TOFU, no detached signature) — same trust model as mise/asdf/nvm/rustup; the sha256 still defends against CDN/mirror corruption. Fix: verify vendor-published signatures where available; support a locally pinned digest.
- Overridable recipe registry (
$SKT_REGISTRY_DIR) takes precedence over embedded recipes silently → source substitution + sticky cache poisoning (slot key omits URL/checksum). Fix: require https for external recipes, gate the override behind an opt-in, bind the cache slot to a hash of(download-url, checksum). - setuid/setgid bits preserved from extracted archives — neutralized by rootless extraction; mask them anyway.
Refuted findings
Three findings were dismissed by the adversarial pass — recorded so they are not re-raised:
- "
SKLP_RUNC_PATH= RCE via repo.env" — the repo.envflows into the container spec, never into the host process env thatresolveRuncreads. The repo-.env-to-host-code-execution chain does not exist; the override is settable only by an actor already controlling the operator's real host environment. - "Predictable bundle/log paths → symlink TOCTOU arbitrary write" —
the cache lives under the operator's own
~/.cache(0700/0755), not a world-writable dir, so the cross-user symlink precondition is never created by the code. - "Truncated checksum accepted" —
constant_time_eqchecks length first and fails closed; a malformed checksum can never match a real 64-hex digest.
Positive controls (verified good)
What correctly mitigates entire classes of risk — confirmed, so reviewers don't flag them as gaps:
- Embedded binaries are sha256-pinned and verified before install at
go generate(gen/main.go): hash-while-streaming, verify, then atomicrename; the tmp file is removed on mismatch, so a bad download can never satisfygo:embed. The runc v1.4.2 pin matches upstream's PGP-signed checksum. Binaries are gitignored, not committed. - No dangerous host mounts. The only bind mounts are
/proc,/workspace(the repo, by design), and a tmpfs/tmp. No docker socket,/var/run, ssh keys, or host system paths. ServiceVolumesare intentionally ignored. - Rootless userns + dropped caps +
noNewPrivileges— the primary host-isolation boundary, intact. - skt checksum verify is constant-time and fails closed on any malformed input.
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.