Files
werkator/docs/prs/2026-07-08-PR#000-security-audit-hardening.md
Michael HönnigandClaude Opus 5 d0169e57bc Renaming gitTally to Werkator because there is another git-related tool named gittally (#1)
* renaming from gitTally to Werkator

* Rename GitTally to Werkator

`gitTally` is the name of another product in the git space, so the
rename is a precaution; nothing about what the build system does changes.

The name follows one rule: `Werkator` where it is prose, capitalized
where it is a Kotlin type and its file, lowercase everywhere a machine
reads it — the command, packages, paths, configuration keys and values,
the Gitea check context. Environment variables keep their convention and
are uppercase throughout.

Every configuration file is still found under its pre-rename name
(`ConfigFiles`): `.gittally.yml` at the repository root, in a build
worktree and as committed on a branch, `.git/gittally/.gittally.yml` for
the machine layer. The current name wins where both exist, and the old
file is then ignored rather than merged — two files side by side are a
half-done rename, not a layering. Without the fallback an installation
that updated without renaming would not fail: a configuration that is
not found leaves every setting at its default, so it would come up
looking healthy while having forgotten its credentials and its builds.

`docs/werkator-migrationsplan.md` lists what the fallback does not
cover and has to be moved by hand — above all the state directory
`.git/werkator/`, which holds the build history, the control token and
the worktrees, and has no fallback of its own.

`docs/migration-from-legacy.md` is deleted with this: it mapped the
legacy script's environment variables, and every host it addressed has
long since moved to the YAML configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Move the pre-rename state directory at the first start

The configuration is found under either name, the state is not: build
history, control token, auto-build slots and worktrees live at one fixed
path. An installation that updates without moving `.git/gittally` would
not fail — it would come up with an empty history and a fresh control
token, quietly. So the first start moves it instead of the release notes
asking for it.

Only when the old directory exists and the new one does not. Where both
exist nothing is touched and a warning names the leftover: which of the
two is the live state is not something to guess. A failed move is an
error in the log, never an abort — a CI must not hang on it.

The worktrees are dropped rather than moved, since they point at their
old path in both directions; `GitWorktreeWorkspaces` prunes the stale
admin entry and recreates each on its branch's next build. A generated
systemd unit moves with the directory and leaves its symlink dangling,
which is warned about — the running service is unaffected, the next
start is not.

Runs from `CliRunner`, before any command resolves a path under the
directory, and so before the second context of `server` exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Document PR#1: the rename to Werkator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the legacy env-to-YAML conversion from the setup tool

The old bash script configured itself through `GITTALLY_*` environment
variables. The blanket rename rewrote those literals, so the converter
was looking for `WERKATOR_*` — a spelling no host has ever written. Fed
a real legacy file it would have found nothing and written an almost
empty configuration, without an error, which is the same silent failure
this rename is otherwise careful to avoid.

The conversion has served its purpose with the vm2176 to vm4006
migration, so it goes instead of being repaired. What remains is the
setup of a new instance: the preconditions, the credential prompt, and
the machine configuration written mode 600 — now carrying the host's
public URL as well, since that is host-specific too. Everything the
repository builds comes from `init` and its templates.

It also stops emitting a legacy `branches:` section, which step 18 is
about to reject outright.

`docs/plan/00-legacy-analysis.md` and `13-nginx-tls.md` get the real
`GITTALLY_*` spelling back: they record what the old script read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Describe this repository's build with a build definition

Its own `.werkator.yml` still used the deprecated `branches` section
with an `autoBuild` schedule that was switched off. That section is read
only while nothing defines a build at all, and step 18 rejects it by
name — so this repository would have blocked the precondition of that
step, which asks that no configuration still in play carries it.

Nothing about the build changes: `builds.default` with `trigger.onPush`
is a build of every new commit on every branch, which is what the branch
section said. `config:print --full` resolves the definition completely
and logs no deprecation warning any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop documenting the pre-rename fallback for users

Exactly one repository is configured with the old names, and it is
migrated by hand in the same move as this release. The fallback is
therefore a transition of days, not a feature anyone reading the release
notes or the configuration reference has to plan around.

Removed from `releases.html` and `docs/configuration.md`. The mechanism
itself is unchanged and stays described where it is worked on: in
`ConfigFiles`, in `StateDirMigration`, and in the migration plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Bring the PR-doc to its final state

The `statusContext` question is answered and marked as decided rather
than left standing: it is the one value a human reads as a label, and it
stays lowercase because Gitea matches it and the client reads it back,
which makes it a value.

Also records that the pre-rename fallback is deliberately absent from
the release notes and the configuration reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Release v1.0.0: Werkator

The release after 0.9.21 is 1.0.0, because a product that changes its
name is better off counting from one under it. The release note says as
much, so the jump is not read as a claim about maturity — plan steps 14,
17 and 18 are still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Correct the PR-doc about the version

It claimed the PR carries no version bump, which the release commit made
untrue, and records why the number is 1.0.0 instead of 0.9.22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Point the legacy references at the history

`legacy/gitTally` was removed from the tree with the rename, but the
README still described it as a reference kept in the repository, and the
plan told an executing session to read parts of it — including step 14,
which is open.

The README section is gone; `docs/plan/README.md`, step 14 and the
legacy analysis now say where the script actually is
(`git show 7f55068^:legacy/gitTally`). Executed steps and ADR 0004 keep
their wording: they record what was true when they ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Retarget the links in the historic PR-docs

The package rename moved every file the older PR-docs link to, leaving
60 dead links. Only the link targets are rewritten, never the visible
text and never a statement: those documents record what was true when
they were written, GitTally in the prose included. A snapshot may be
outdated; it should still be navigable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add the v1.0.0 deployment procedure for vm4006

Measured, not estimated: the state directory is 878 MB, of which 878 MB
are the nine build worktrees. What cannot be recreated is 84 KB, so the
snapshot before an in-place switch is instant and the rollback is one
sequence of moves.

Records the three expected non-failures — a cold Gradle volume, one
image rebuild, containers left under the old label — and that
`gitea.statusContext` needs no attention because it comes from the
watched repository's committed configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Rename the machine configuration along with its directory

Found by the deployment to vm4006: the move renames the directory and
leaves the file inside it alone, so the machine configuration ended up at
`.git/werkator/.gittally.yml` — a pair of names the lookup did not
expect, because it pairs directory and file name. The instance resolved
empty credentials, no public URL and none of the host's build
definitions, and said nothing about it. That is the exact failure this
change exists to prevent, produced by the change itself.

`StateDirMigration` now renames the configuration with the directory,
unless one under the current name is already there. `ConfigFiles` carries
`.git/werkator/.gittally.yml` as a third candidate as well, for a
directory somebody moved by hand, where the migration never runs and so
can rename nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let init see a configuration under its previous name

`init --systemd` runs `init`, and its "already exists" check knew only
the current name. On the one repository still carrying `.gittally.yml`
it therefore wrote a fresh template `.werkator.yml` beside it — and
since the current name wins, that repository would have built the
template's `./gradlew test` instead of what its own configuration says.
Found on vm4006, where the file was created in the watched working tree
and removed again by hand.

Both checks now ask `ConfigFiles`, so init decides existence by the same
rule the loader uses to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record the v1.0.0 deployment to vm4006

Deployed from the branch as the final test of PR#1, and it did what a
final test is for: it found two silent-failure defects before the
service was started, both fixed and redeployed in the same window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the control token left under the old localStorage key

The key is named after the product, so the rename left every browser
with a token under `gittally.controlToken`, which nothing reads any more
and which "forget token" can no longer reach. It is a write-scope token
in a browser store, not a password, but a secret nobody owns is worth
one line to remove.

Removed on load. The token on the server is unchanged, so re-entering it
once per browser is all the rename costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:32:54 +02:00

18 KiB

Security Audit and Hardening

WARNING: This document describes only the change applied in this PR. It may already be outdated once the next PR is merged. Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.

  • ADR 0004 — server-rendered UI, systemd behind the host's reverse proxy (the deployment posture this audit assumes).
  • ADR 0005 — opt-in managed nginx/TLS container for hosts without a reverse proxy (e.g. Hostsharing, a multi-tenant host — relevant to the secret-file findings).

The Audit Summary

A security audit of the Kotlin/Spring source was performed on 2026-07-08. It found no critical injection or memory-safety class bug — the classic sinks are correctly closed (see Verified Safe). The real weaknesses are deployment-posture and secret-handling issues.

Two threat actors frame the findings:

  • Anyone who can push a branch or open a pull request — they influence branch names and the built commit.
  • Anyone who can reach the HTTP port — the UI and API are unauthenticated by default and bind 0.0.0.0.

The whole security model leans on an external reverse proxy providing access control. Nothing in the app enforces that, and the one in-app control (the control token) is undermined by how it is distributed. The highest-impact confidentiality issue is a world-readable Gitea-token file on exactly the multi-tenant host GitTally is meant to run on.

This PR tracks the remediation as a checklist. It may be split into smaller PRs per severity; the filename and scenario IDs use the PR#000 placeholder until a pull request is opened.

Non-Goals

  • The applicaiton has deliberately no authentication/authorization layer (no Spring Security, no user accounts) — the reverse proxy stays the primary access gate.
  • No re-audit of the confirmed-safe areas; they are recorded here so future changes do not silently regress them.
  • TODO 6 introduces a layered config where the build worktree overrides build-specific keys; it does not let the worktree override secrets, Gitea/server settings, or the sandbox policy — those stay pinned to .git/primary.

The Identified Issues

The remediation plus one design change (TODO 6), grouped by severity. Each item is a TODO with the background that justifies it.

High

TODO 1 — Restrict the Gitea-token config file to the owner at creation

  • In InitCommand.kt:86-106, create .git/gittally/.gittally.yml and its parent .git/gittally/ with 0600/0700, atomically at creation (as GitAskPass does), not via plain writeText at the umask default. Done: both init paths now go through SecretFiles, which sets the mode as a file attribute at creation.

Background. init creates the file it labels "secrets" — where the operator pastes the Gitea API token — with writeText and no permission restriction, so it inherits the umask (typically 0644, world-readable). The shell path (tools/setup-gittally-instance:245-253) already does chmod 600 on its equivalent file, and ControlTokenService/GitAskPass both restrict theirs — the Kotlin init path is the lone exception. On a shared host (Hostsharing is a multi-tenant deployment target, per ADR 0005) any local user can then read the token, which grants git push and commit-status access to the repo.

TODO 2 — Stop handing the control token to every unauthenticated reader

  • Reconsider the token distribution in UiController.kt:186 and fragments.html:9: do not embed the live token in public HTML, or gate the pages behind the same check as the mutations. Done in v0.9.10, without gating the pages. Public read access is a requirement, not an oversight: build states, logs and artifacts must stay linkable without a login. So the <meta> tag is gone and gittally.js keeps the token in localStorage, asking for it once per browser (the operator reads it from .git/gittally/control-token, which needs shell access to the host). The token is a real secret again, and as a request header it stays inherently CSRF-safe — a foreign origin cannot set it without a CORS grant. A login with a session cookie (the alternative that would also hide the buttons from visitors) stays possible later; it would revise ADR 0004 and needs its own PR.
  • At minimum, document loudly that under the current design any read access equals full write access. Superseded: read access no longer implies write access. docs/deployment.md gained a "Control Token" section describing the split.

Background. Every server-rendered page embeds the live control token in <meta name="gittally-control-token"> so gittally.js can read it, but no GET is authenticated. So curl -s http://host:18080/ | grep gittally-control-token yields the token, which unlocks restart/cancel/delete. Read access therefore equals write access, and the token provides no real second trust tier. Blast radius is limited to build-lifecycle operations (a DoS/integrity concern, not secret disclosure or RCE), which is why this is a design flaw rather than Critical — but the token gives a false sense of protection.

Medium

TODO 3 — Accept the control token via header only

  • Remove the token query-parameter variant from the three mutating endpoints in BuildsApiController.kt:90,115,129; keep only the X-GitTally-Token header (which the bundled UI already uses).

Background. Tokens in URLs are routinely written to access logs, reverse-proxy logs, browser history, and the Referer header on outbound navigation. The token never expires (static file secret), so any historical log capture yields a still-valid credential. The query-param path exists only for legacy convenience.

TODO 4 — Redact the Gitea token in config:print

  • Mask git.token (and any future secret) by default in ConfigPrintCommand.kt:20-31; gate the plaintext value behind an explicit --show-secrets flag. Masked as *** on both the --full and the raw path, with a leading YAML comment naming the flag, so the output stays parseable when piped.
  • Update tools/setup-gittally-instance:272, which currently steers the operator to run config:print --full to view the token.

Background. Both the --full and default branches print the token verbatim to stdout, landing it in terminal scrollback, script(1) captures, screen-shares, or CI logs. There is no redaction and no masked default.

TODO 5 — Reduce information disclosure on the unauthenticated read API

  • Decide whether build-log streaming (BuildsApiController /api/builds/current/{key}/log), SystemApiController, and WatcherApiController should stay fully public, or be gated / scrubbed. Decided: they stay fully public, no scrubbing. The watched projects (GitTally itself and hs.hsadmin.ng) are open source, the repositories hold no secrets, and the builds run tests against test data — credentials appearing in a log are fixtures, not real ones. Builds neither deploy nor sign; the only planned artifact is a jar. Public logs are also the point of the tool: a red build must be diagnosable from the link in the Gitea status without a login. This is a property of the watched project, not of GitTally: an installation whose builds touch real credentials must keep its instance off the public internet (reverse proxy or bindAddress: 127.0.0.1), because GitTally offers no per-endpoint gating.

Background. Every read endpoint is public. Raw build output may contain secrets echoed by build scripts; /api/system exposes host metrics; /api/watcher exposes last fetch/poll error strings, which can leak git remote URLs or error detail.

Design change — layered build config (Medium)

TODO 6 — Layer build config over the worktree, with secrets and sandbox pinned to .git

A branch is built with its own build settings: .gittally.yml from the build worktree (the commit being built) overrides the .git/primary config — except for a pinned set that a branch must never control. Implemented in this PR.

  • Worktree config layer for builds via ConfigLoader.loadForWorktree, wired into the two build-time config consumers (BuildExecutor.branchConfig, FileArtifactStore.branchConfig) — both already hold the prepared worktree path. Precedence is worktree > .git > project.
  • Pinned to .git/primary — stripped from the worktree layer: git, gitea, server (secrets + server-side), and the sandbox policy docker.enabled/docker.network. A branch cannot disable its container or change its network mode.
  • Worktree-overridable: buildCommand, cleanCommand, artifactDirs, stdoutLog/stderrLog, autoBuild, and docker.image/dockerfile/context/env.
  • Pinned set enforced in code (ConfigLoader.stripPinned), documented in docs/configuration.md, and asserted as an invariant in AGENTS.md.
  • Deferred: autoBuild scheduling and requirePullRequest are still read from the primary config, not the worktree — the watcher evaluates them before a build (and thus a worktree) exists. Sourcing them per-branch would need the watcher to read the branch's committed config directly (e.g. via git show <branch>:.gittally.yml); out of scope here.

Background. The build command is executed via bash -c "$3" inside the build container (DockerBuildRunner.kt:285). Letting a branch define its own buildCommand is not a new risk — a CI already runs arbitrary code from that commit; the container is the sandbox. The real escalation is a branch turning the sandbox off: if the worktree could set docker.enabled: false (or host docker.network), the build would run natively on the host — which is why those two keys are pinned. Secrets are also safe from the build process (the Gitea token is used only by the server/watcher and is never placed in the build environment — runCommand passes only mapOf("branch" to ...)), and the whole git/gitea/server sections are stripped from the worktree layer as defense in depth. Before this PR all build config was loaded from the primary checkout via configLoader.load(build.workingDir), the worktree's .gittally.yml was never consulted, and .git took precedence over the committed project config — so this both adds the per-branch feature and reverses that assumption.

Low / defense-in-depth

TODO 7 — Default bindAddress to 127.0.0.1

  • Change the default in GitTallyConfig.kt:21 and the init template (InitCommand.kt:126) from 0.0.0.0 to 127.0.0.1; require operators to opt into all-interfaces. Shipped as v0.9.9 with the migration note in the release notes, docs/configuration.md ("Notes on server.bindAddress") and docs/deployment.md: existing configs keep their explicit value, and the managed nginx now needs 0.0.0.0 set deliberately.

Background. The current default binds all interfaces, which — combined with the public read surface and token-in-HTML — exposes the whole UI and the control token to the network whenever the reverse proxy is forgotten. The deployment doc already recommends 127.0.0.1; the default and template should match it.

TODO 8 — Compare fixed-length hashes in the token check

  • In ControlTokenService.kt:35-37, compare SHA-256(submitted) against SHA-256(secret) with MessageDigest.isEqual, so the comparison is always over equal-length buffers.

Background. MessageDigest.isEqual returns early on a length mismatch, leaking the token length via timing. Largely theoretical given the 192-bit CSPRNG token, but a cheap deviation from constant-time best practice to close.

TODO 9 — Add -- before positional refnames in git calls

  • Insert -- before the branch argument in checkout, fetchBranch, and resetHardToOrigin in GitService.kt:145,40,155 (e.g. git switch -- <branch>). Done for checkout and fetchBranch. resetHardToOrigin keeps its plain form: git reset --hard -- <commit> is rejected (fatal: Cannot do hard reset with paths), and its argument is already prefixed with origin/, so it can never start with -.

Background. Git accepts refnames beginning with - (verified: git check-ref-format 'refs/heads/-foo' exits 0), so a pushed branch name could in principle be read as a git option. These three methods currently have no production callers and the actively-used paths embed the branch in a refs/... prefix, so this is dormant — but the guard should be in place before any of them is wired to server-triggered input.

TODO 10 — Create secret files restricted atomically

  • Set the mode at creation for the control-token file (ControlTokenService.kt:29-30) and the setup-script YAML (tools/setup-gittally-instance:253), instead of chmod 0600 after the write. Done: the control-token file via SecretFiles, the setup script by writing the YAML in a umask 077 subshell instead of chmod-ing afterwards.

Background. Both currently write the file at the umask default and tighten it afterward, leaving a brief window where the secret exists world-readable. A small TOCTOU gap; GitAskPass's atomic-at-creation approach is the pattern to copy.

Open Questions

  • TODO 2: full fix (gating the pages) versus documentation-only. Settled by the operator: unauthenticated read access is a requirement — build states and artifacts must stay linkable without a login. So the pages are not gated; only the token distribution changed (v0.9.10).
  • TODO 5: whether public read access is acceptable by design (it matches legacy) or should change. Settled by the operator: public reads are intended, and the watched open-source projects put no real secrets into their logs. See TODO 5 above.
  • TODO 6: the pinned set is settled (secrets + Gitea/server + docker.enabled/docker.network); the open point is whether docker.env should also be pinned, since a branch overriding it controls its own container's environment (currently proposed as worktree-overridable).
  • TODO 7: changing the default bindAddress is a behavior change for existing installs that rely on 0.0.0.0; needs a migration note. Settled: the default changed in v0.9.9 and the migration note is in the release notes and both deployment docs.

Additional Changes

  • Update docs/deployment.md to state explicitly that the reverse proxy is a hard requirement, not a recommendation, and that read access currently implies write access via the embedded token (until TODO 2 lands).

Follow-up PRs

  • If TODO 2 grows into real UI authentication, that is a separate PR with its own ADR (it revises the "no auth layer" stance of ADR 0004).

Attachments

Verified Safe (checked, not vulnerable)

Recorded so future changes do not silently regress these.

  • Path traversal in artifact servingArtifactFileController normalizes then enforces startsWith(artifactDir) and isRegularFile(..., NOFOLLOW_LINKS) (rejects symlink escape); FileArtifactStore whitelists the key to [A-Za-z0-9._-]+ and requires dir.parent == branchesDir.
  • Command injection — every git/docker/certbot/nginx call uses ProcessBuilder(List<String>) (GitCommandRunner.kt); no Runtime.exec(String), no shell-string interpolation; the only sh -c/bash -c uses pass data as positional args.
  • Branch-name → filesystem — always routed through ArtifactKeys.sanitize (/_) plus a SHA suffix, so worktree/container/volume names cannot traverse.
  • XSS — no th:utext in any template; gittally.js builds all DOM via createElement + textContent/dataset, no innerHTML.
  • SSRF — the Gitea status proxy validates the commit against [0-9a-fA-F]{7,40} (StatusApiController.kt); the Gitea base URL/owner/repo/token come from config, not the request.
  • Deserialization — Jackson JSON/YAML without polymorphic/default typing (no gadget-chain RCE); unreadable state files degrade to empty.
  • Credential transport — Gitea token sent as an Authorization header, never in a URL (GiteaClient.kt); git auth via env-based GIT_ASKPASS with a 0700 script containing no secrets, deleted in finally; TLS verification never disabled.
  • nginx/certbotserverName/upstreamHost validated against [A-Za-z0-9][A-Za-z0-9.-]* before substitution (NginxProxyManager.kt); ports range-checked.