civm is operational infrastructure: a self-hosted GitHub Actions runner
provisioning toolkit (civmctl) plus systemd timers and managed runner
hook scripts. This doc describes the threat surface, the validations that
defend it, and how to report issues.
For anything that could let an unprivileged actor escalate to runner privileges or compromise the VM, contact the maintainer privately first — do not open a public issue. Contact the repository owner via a private channel (security advisory / DM) before public disclosure. Do not paste live tokens or private keys into issues.
For ordinary bugs that are not security-relevant, regular GitHub issues are fine.
Invariant: no secret values in git (tokens, private keys, passwords). Paths and names of env vars / GitHub Actions secrets are OK.
| Class | In repo? | Where real values live |
|---|---|---|
RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY |
name only | GitHub Actions secrets |
RELEASE_PLEASE_TOKEN (optional) |
name only | GitHub Actions secrets |
Host C:\ProgramData\civm\gh-token-*.txt |
path only in docs | Windows host filesystem |
| SSH host→guest key | path only | C:\ProgramData\civm\ssh\ (SYSTEM) |
Guest /etc/civm/*.env, ~/.config/gh |
never | guest host state |
Runner .credentials / registration tokens |
never | ephemeral / guest dirs |
Before flipping the repo to public:
- Confirm CI Secret pattern scan and Gitleaks are green on default branch.
- Keep lab session logs gitignored and local-only:
MEMORY.md,validation.md(agents append here; never publish).vm.mdmay stay tracked as generic runner inventory. - Prefer
ubuntu-latestfor public free CI (default in this repo). Optional lab smoke: set repository variableCIVM_SELF_HOSTED_SMOKE=truewhen acivmrunner exists. - Never commit scratch scripts that
echoregistration tokens or hardcode guest IPs. - History: gitleaks scans full git history on every full CI run. If a real token is ever found, rotate it immediately; rewriting public history is hard once mirrored.
- Module path is still
github.com/emersonbusson/civmuntil the Go module / GitHub transfer is intentional. - Host secrets (
C:\ProgramData\civm\gh-token-*.txt, SSH underProgramData\civm\ssh) must never be copied into this repository.
The civm runner is a shared resource across peer repos (peer,
acme, etc.). Multiple jobs from different repos can run
concurrently on the same VM. Each job ships with whatever code its
authors push — so untrusted input includes:
- Repository source code at checkout time
- Action payloads (
actions/checkout, third-party actions) - Environment variables propagated by the GitHub Actions runner
- Files written under
_workduring the job - Anything an action chooses to run via shell
The trusted set is:
civmctlbinary at/usr/local/bin/civmctl(only operators can install or replace; seecivmctl self-upgrade)- systemd unit files in
/etc/systemd/system/civmctl-*.{service,timer} - Target-state hook scripts at
/opt/civm/hooks/job-{started,completed}.shexecuting the trusted binary. Some legacy VMs can still have stale symlinks or custom wrappers untilcivmctl hook install --executeis run with a fresh binary; seerunbooks/MULTI-PROJECT-RUNNER.md.
Implicit assumptions:
- The runner OS is Ubuntu 24.04 LTS;
civmctl bootstrapenforces this before any apt operation. - The hook process runs with the runner user's privileges, escalated
via
sudoonly for specific allowed commands (apt-get clean, journalctl --vacuum-time, fstrim).
internal/hook/safeWorkRoot validates every candidate work-root path
before os.RemoveAll. The historical bug (caught by FuzzSafeWorkRoot
in PR #26) was that filepath.Clean does not resolve .. at the
start of a relative path — so ../home/x/actions-runner/_work slipped
through a strings.Contains(clean, "/home/") check. The fix enforces:
filepath.IsAbs(clean)— must be absolutestrings.HasPrefix(clean, "/home/")— prefix, not substringstrings.Contains(clean, "/actions-runner")— runner-shapedstrings.HasSuffix(clean, "/_work")— work-root literal
The fuzz harness asserts no traversal component (.. as a path element,
not as a substring like ..0) survives in the cleaned path. The
crashing input that uncovered the bug is committed at
internal/hook/testdata/fuzz/FuzzSafeWorkRoot/.
internal/civm exposes Validate* regex functions that gate any CLI
flag value that ever appears in a subprocess argv:
| Validator | Pattern | Used by |
|---|---|---|
ValidateRepo |
^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$ |
runner, billing, cireport, peerstatus |
ValidateShort |
^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ |
runner directory suffix |
ValidateLabels |
comma-split ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ |
runner labels |
ValidateSemver |
^[0-9]+[.][0-9]+[.][0-9]+$ |
runner version, Go version |
ValidateServiceUnit |
^[A-Za-z0-9_.@-]+[.]service$ (no ..) |
systemctl restart targets |
ValidateUserName |
^[A-Za-z_][A-Za-z0-9_-]{0,63}$ |
--run-as user |
ValidateWorkflowFile |
^[A-Za-z0-9.][A-Za-z0-9._/-]{0,127}[.]ya?ml$ (no / prefix, no ..) |
gh workflow selectors |
Any caller skipping validation is a regression — gosec G204 is
acknowledged but excluded globally because subprocess argv is always
gated by these validators (see .golangci.yml for the rationale).
Hook policy in internal/hook is exercised through managed scripts at
/opt/civm/hooks/job-{started,completed}.sh. Each script contains only a
static exec /usr/local/bin/civmctl hook <event> --execute "$@" adapter
because the GitHub Actions runner executes .sh hooks through bash.
This means:
- The runner can never invoke arbitrary code via the hook env vars, only the managed script and validated civmctl binary.
- A compromise of the hook path or binary path requires write access to
/opt/civm/hooks/or/usr/local/bin/(root-only). civmctl self-upgradeperforms the binary swap viaos.Renameinside the same directory (atomic per POSIX) so concurrent invocations never see a half-written file.
internal/hook/cleanup differentiates routine cleanup (job-completed)
from disk-pressure cleanup (job-started with disk >= threshold). The
former preserves $HOME/.cache/go-build and similar build caches; the
latter purges them. Conflating the two cost recurring CI failures
(PR #31 fixed it). Tests
TestJobCompletedPreservesHotCachesUnderHome and
TestJobStartedPurgesHotCachesUnderDiskPressure lock that behavior.
/var/log/civm/hooks.jsonl is emitted via slog.JSONHandler with
level derived from decision (ERROR for error, WARN for rejected,
INFO otherwise). World-readable (0644) by design — operators and log
shippers (Vector/Loki) consume it. //nolint:gosec annotation on the
open call documents the intent.
.golangci.yml excludes a small set of gosec rules with rationale:
| Rule | Reason |
|---|---|
| G115 | Disk arithmetic (uint64 → int percent, uint64 → int64 GB) is bounded by realistic filesystem sizes and percent ranges. |
| G204 | All subprocess argv values are validated by internal/civm.Validate* regexes before reaching exec.CommandContext. |
| G304 | Path traversal: paths come from validated CLI flags (CleanDir) or from a whitelisted glob in internal/hook (safeWorkRoot/safeRunnerDir). |
When in doubt, prefer a per-line //nolint:gosec // motivo annotation
over expanding the global exclude list.
If a deployed civmctl version is found to have a security issue:
- Stop the bleed. On the runner host, downgrade by disabling the
hook env vars in every
/home/*/actions-runner*/.env:The runner keeps working without the hook (no cleanup between jobs, but no compromised hook either).sudo sed -i '/^ACTIONS_RUNNER_HOOK_/s/^/# /' /home/*/actions-runner*/.env sudo systemctl restart actions.runner.*
- Fix. Land the patch on
main. Conventional Commits + therelease-pleaseautomation produces a release PR. - Roll forward. Once a release with the fix is cut:
If the host predates
cd /opt/civm && git pull --ff-only sudo civmctl self-upgrade --execute
self-upgradeor/opt/civmis not a Git checkout, first verify the runner is idle (civmctl idle-check), build the release binary from a trusted checkout, copy it to the VM, and install it atomically withsudo install -m 0755 <binary> /usr/local/bin/civmctl. Then runsudo civmctl hook install --executeto replace legacy hook symlinks or custom wrappers with managed.shscripts that execute the trusted binary. - Re-enable hooks. Reverse step 1 on each runner.
release-pleaserequires either repo setting "Allow GitHub Actions to create and approve pull requests" to be enabled, or a PAT withreposcope stored as secretRELEASE_PLEASE_TOKEN. The workflow at.github/workflows/release.ymlreads the secret with fallback.- The hook is intentionally idempotent and tolerant: every
civmctl hook install --executeis safe to re-run; legacy.shwrappers from before PR #26 are cleaned up automatically.