Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

#11

P1: Harden the CI runner trust boundary

closed · opened by keenan 13d ago

keenan opened this 13d ago

Why

The runner is the highest-risk component. Current defenses are good, but:

  • client/remote.go:15 does not require SSH host-key verification; a MITM or loose known_hosts can feed a runner malicious jobs.
  • ci/agent/worktree.go:64 uses job.Repo and runID directly in filepath.Join without validation.
  • ci/agent/agent.go:796 (publishSite) uses job.Pages.Path directly; a compromised/buggy server could point it at ../../.
  • job.SHA, job.Ref, job.Tag, job.Branch, artifact names, and step metadata are not all re-validated runner-side.
  • ci/agent/agent.go:906 (logBuffer) and uploadLog keep entire logs in memory/disk with no cap.
  • Artifacts have no per-run total/count cap beyond workflow limits.
  • All contributors share one cache namespace under contrib.

Scope

  • Require strict SSH host-key verification for runners by default; add a known-hosts flag and an explicit test-only override.
  • Validate every job field on the runner before use.
  • Share pages/artifact validation between server and runner.
  • Reject paths containing :, NUL, or newlines before building --volume arguments.
  • Cap job logs, artifact count, total artifact bytes, and per-run disk usage.
  • Separate contributor caches by account or tip.
  • Keep image builds rootless and pinned by digest; document build-time network access.
  • Add a test asserting container.RunArgs never contains --privileged, --cap-add, /var/run/docker.sock, or unexpected host mounts.

Acceptance

  • A malicious job payload with repo=../../..., pages.path=../../..., a bad SHA, or a symlink artifact is rejected/contained.
  • Runner memory/disk stays bounded under a noisy job.

Comments (1)

keenan 12d ago

Working through this in two change requests, because the two halves fix different failures and review better apart.

1. Trust the channel, validate the payload (in review)

  • Host-key verification on by default for every SSH call the client makes — the runner’s control channel, git’s own clone/fetch/push, and the LFS transfer — with --known-hosts <path>, VOLE_KNOWN_HOSTS, and an explicit --insecure-host-key / VOLE_INSECURE_HOST_KEY for tests. Our options are placed before any configured ones, because ssh takes the first value: a GIT_SSH_COMMAND in the environment can no longer turn the check off. The test suite opts out explicitly rather than learning to trust everything.
  • The runner re-validates every field of a leased job before it acts on it: repository name, commit id, ref, branch, tag, run id, attempt, workflow and job names, image or image-file + pinned digest, steps, artifact patterns, pages path and environment. A malformed payload stops the runner with the reason instead of being turned into a path or a container argument.
  • The artifact and pages path rules move into ci so the push-time check and the runner use the same code, rather than two copies that can drift.

2. Bound what a job can consume (next)

  • Logs become a bounded spool on the runner instead of a strings.Builder for the whole attempt, then one ordinary LFS upload at the end. Nothing about the server changes, and no log bytes go anywhere except the LFS object store. The current path is worse than “in memory”: the attempt is buffered in RAM, written to a temp file, then read twice by the transfer (once to hash, once to send). A spool fixes that and gives the cap a natural place to stop.
  • Caps for artifact count, total artifact bytes and per-run disk; contributor caches keyed by account or tip; image builds rootless and pinned by digest, with build-time network access documented.

Deliberately not doing: streaming logs to the server through a new append verb. A log’s content-addressed id cannot be known until it is complete, so “stream into LFS” means either a local spool uploaded once at the end (what part 2 does) or uploading fixed-size chunks as ordinary LFS objects during the run and naming the chunk list in the record. The second is a protocol and record change with a real decision attached (live logs, partial logs from a runner that died), so it belongs in its own issue rather than in a hardening pass.