Files
felhom.eu/scripts/manifest_bearer_gate.py
T
admin e467785fd3
gates / gates (push) Successful in 5m23s
security: rotate the published secrets and de-git felhom.secret.yaml (R-925, P1)
WHY .gitignore "was not working": it was working. git never consults
.gitignore for a file it ALREADY TRACKS. The rule `*secret*` matched fine --
proved by dropping an untracked copy in and watching check-ignore name
`.gitignore:3:*secret*`. The file had been tracked since feea0606, which is
ironically the commit that de-gitted the Resend key.

WHAT THE EXPOSED VALUE ACTUALLY WAS. Not an analytics password: the GITEA
ADMIN ACCOUNT PASSWORD (is_admin true; /api/v1/admin/users answered 200), in
a repo gitea.dooplex.hu serves anonymously to the internet. That is push
access to every repo -- including the one whose website/ is git-synced live
and whose scripts/ is published by tag to every new box installer (R-110).
Re-ranked P2 -> P1 on that measurement; my first ranking had only measured
the analytics blast radius.

Every committed value was still live. Nothing had ever been rotated.

ROTATED (values never echoed; written to a 0600 file on DooPlex):
  umami-config        APP_SECRET + POSTGRES_PASSWORD. The password was
                      changed INSIDE postgres (ALTER USER) as well as in the
                      Secret -- the env var is only read at first init, so
                      patching the Secret alone would have changed nothing.
  healthchecks-config SECRET_KEY + SUPERUSER_PASSWORD (nothing consumes them,
                      there is no healthchecks Deployment).
  gitea-creds         no longer holds the admin password at all: a SCOPED
                      token (read:package + read:repository).
  gitea admin         new random password; gitea-system/gitea-admin updated.

VERIFIED, not assumed:
  - new admin password -> 200, OLD PUBLISHED PASSWORD -> 401 (the leak is dead)
  - umami: a real beacon returns 200 (so the app authenticates to postgres and
    writes) while a bogus site id still returns 400 (so the 200 means something)
  - hub: "Registry version check: latest = 0.304.0" AND "Template fetched
    (5881 bytes)", no auth failures
  - BOTH token scopes are load-bearing, and the second was found by breaking
    it: a package-only token made the hub log "Template fetch: unexpected
    status 403", because the template fetcher reads a raw file out of the
    felhom-controller repo, not the registry.

AN INCIDENT CAUSED BY THE FIX, recorded because it is the useful part: the
rollout restart needed to pick up the new umami secret put umami into
CrashLoopBackOff and took stats.felhom.eu down (503) for ~4 minutes. Not the
rotation -- at memory 512Mi that pod runs for months but CANNOT RESTART:
startup (Prisma + Next.js) peaks over the limit and is OOMKilled (exit 137).
Raised to 1Gi IN THE MANIFEST, not just live, per .claude/rules/manifests.md
("never bare kubectl set -- the next sync reverts it and the fix silently
disappears").

THE GATE: KNOWN_BACKLOG is removed from manifest_bearer_gate.py, as its own
comment instructed. Red-proofed with a decoy: exit 1 with it, exit 0 without.
An exemption kept this visible for three months and changed nothing.

WHAT REMAINS (operator, and it is bigger than what was fixed): the same
password is still the admin password in ~12 other namespaces -- nextcloud,
paperless, bookstack (a DATABASE ROOT password), tandoor, calibre,
adventurelog, gokapi, qbittorrent, servarr, homepage. Rotating Gitea does not
touch them. Also owed: a kisfenyo Gitea token sits in plaintext in the local
homelab-manifests remote URL and was printed to a session transcript during
this investigation, so it should be replaced regardless (R-580's shape).

NOT a finding: homelab-manifests is private (404 anonymously) and does not
contain the password; ArgoCD's repo credential is a separate token and was
untouched by the rotation.
2026-10-09 13:21:18 +02:00

62 lines
3.1 KiB
Python

# -*- coding: utf-8 -*-
"""Manifest bearer-literal gate (v0.53.0, part of the hub bearer de-git) — no bearer-shaped
literal (64 hex chars, the `openssl rand -hex 32` shape every felhom bearer/API key uses) may
appear ANYWHERE in manifests/, comments included. Secrets ride out-of-band `kubectl create
secret` + secretKeyRef (documentation/runbooks/secrets.md); the manifests carry only
placeholders. The other known committed secrets in felhom.secret.yaml (passwords, non-hex
shapes) are a tracked backlog item (secrets.md) and are NOT matched by this gate — extend the
patterns when they are de-gitted.
Run from the repo root: python scripts/manifest_bearer_gate.py
Exit 1 on any hit.
(Named "bearer", not "secret": the repo .gitignore's `*secret*` pattern — which guards real
secret files — would silently un-track a gate with "secret" in its filename.)
"""
import io, os, re, sys
ROOT = "manifests"
# 64 hex chars with no hex/word neighbors (so longer blobs and sha256-of-file hexes embedded in
# longer strings still match at 64+, but ordinary short ids never do).
BEARER = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
# The felhom.secret.yaml carve-out is GONE (2026-10-09, R-925). That file committed the Gitea admin
# password, the umami APP_SECRET and more, into a repo gitea.dooplex.hu serves to anyone on the
# internet with no login. Every value was rotated and the file was removed from git; `.gitignore`'s
# `*secret*` rule applies to it now that it is no longer tracked. The carve-out's own comment said
# to remove it when the file was cleaned, so this is that removal — and nothing may be added back
# here: an exemption is how the last one stayed visible for three months and changed nothing.
KNOWN_BACKLOG = set()
def main():
total = 0
# AT ANY DEPTH. Was os.listdir(ROOT), one level only — measured 2026-09-01 to miss a
# bearer-shaped literal in manifests/overlays/ (R-421). There are no subdirectories today; an
# overlays/ or base/ directory is an ordinary thing to add, and the gate would have stayed green.
paths = []
for dirpath, _dirs, names in os.walk(ROOT):
for fn in sorted(names):
if fn.endswith((".yaml", ".yml")):
paths.append(os.path.join(dirpath, fn))
for path in sorted(paths):
fn = os.path.basename(path)
for lineno, line in enumerate(io.open(path, encoding="utf-8", errors="replace"), 1):
for m in BEARER.finditer(line):
masked = m.group(0)[:8] + "..." + m.group(0)[-4:]
if fn in KNOWN_BACKLOG:
print("%s:%d KNOWN-BACKLOG committed secret %s (secrets.md de-git backlog; not this gate's failure)"
% (path, lineno, masked))
continue
total += 1
print("%s:%d bearer-shaped literal %s" % (path, lineno, masked))
if total:
print("MANIFEST BEARER GATE FAILED: %d bearer-shaped literal(s) in manifests/" % total)
sys.exit(1)
print("manifest bearer gate OK - no bearer-shaped literals in manifests/")
if __name__ == "__main__":
main()