thermograph/infra/.claude/skills/key-gaps/key_gaps.py
Emi Griffith 7ed375f32e key-gaps: the key regex was blind to digits, inventing missing secrets
`grep -oE "^[A-Z_]+="` cannot match a key name containing a digit. This estate
has exactly six such names — all the *_S3_* keys — so a prod audit returned 26
keys against 32 real ones, and reported the S3 and lake credentials as missing
from both live hosts.

They were present the whole time. The phantom was independently reproduced
twice by re-running the same pattern, which is what made it convincing, and it
was briefly recorded as one of two root causes of the lake being unqueryable
(#56). That issue has one cause: the missing duckdb-lake image.

An audit that under-reports is worse than no audit. A missing-secret finding
sends someone to provision a credential that already exists, and in a rotation
tool it would justify writing over one.

Fixed to `^[A-Z][A-Z0-9_]*=` in both the skill and key_gaps.py's docstring. The
match still stops at the `=`, so no value is read — that property is the reason
this grep exists rather than a parser.

Centralis's secrets_gaps carried the same bug and is fixed separately, with a
regression test.

Claude-Session: https://claude.ai/code/session_0182KTMrsTHJc3TcewCatJFY
2026-07-24 15:57:15 -07:00

216 lines
9.5 KiB
Python
Executable file

#!/usr/bin/env python3
"""Audit secret-key coverage across Thermograph environments.
Reports, per environment and across them:
- CRITICAL gaps: a required, *self-generating* secret is missing. Missing here is
the worst case — the app silently mints a new value on boot (AUTH_SECRET, VAPID,
INDEXNOW), invalidating every session / push subscription.
- Required gaps: a required secret is missing.
- Feature gaps: a feature is *partially* configured (some keys present, some not),
so it's silently broken. Fully-unset features are "off", not a gap.
- Dependent-key gaps: an optional key is set but the OTHER key it needs to do
anything isn't (e.g. a Discord channel id with no bot token) — asymmetric,
unlike feature gaps: the prerequisite is fine set alone.
- Drift: a key present in one environment but absent in another.
It reads only KEY NAMES, never values — safe to run anywhere. Sources per environment:
* a SOPS-encrypted YAML (deploy/secrets/<env>.yaml) — keys are plaintext even when
encrypted, so no age key or decryption is needed; or
* a plain key-list file (one KEY per line, or KEY=... lines) — e.g. the output of
`ssh <box> 'grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph.env'` for a live audit.
Usage:
key_gaps.py prod=deploy/secrets/prod.yaml beta=deploy/secrets/beta.yaml
key_gaps.py prod=/tmp/prod.keys # a live key-list gathered over SSH
Exit status is non-zero if any CRITICAL or required gap is found (usable in CI).
"""
from __future__ import annotations
import re
import sys
# --- what counts as a secret/key, and how it behaves --------------------------
# category: "required" (app needs it) | "optional" (fine to omit)
# self_gen: missing => the app silently generates a replacement (data-loss hazard)
REQUIRED = {
# key: self_generating?
"POSTGRES_PASSWORD": False,
"THERMOGRAPH_AUTH_SECRET": True,
"THERMOGRAPH_VAPID_PRIVATE_KEY": True,
"THERMOGRAPH_VAPID_PUBLIC_KEY": True,
"REGISTRY_TOKEN": False,
}
OPTIONAL_SELF_GEN = {
"THERMOGRAPH_INDEXNOW_KEY": True, # regenerates, but persisted to a file — low risk
}
# Features that only work if ALL their keys are set together; some-but-not-all = broken.
FEATURE_GROUPS = {
"discord-account-linking": ["THERMOGRAPH_DISCORD_APP_ID", "THERMOGRAPH_DISCORD_CLIENT_SECRET"],
"mail-smtp": ["THERMOGRAPH_SMTP_USER", "THERMOGRAPH_SMTP_PASSWORD"],
}
# Optional keys that only do anything when a specific OTHER key is also set. Unlike
# FEATURE_GROUPS the relationship is asymmetric: the prerequisite is fine set alone
# (THERMOGRAPH_DISCORD_BOT_TOKEN alone already enables DMs), but the dependent key
# alone is a silent no-op (discord.py gates both channel posts on
# `bool(BOT_TOKEN and <channel>)`) — flagging it as a symmetric feature group would
# false-positive on every environment that has the bot token for DMs only.
DEPENDENT_OPTIONAL = {
"THERMOGRAPH_DISCORD_WEATHER_CHANNEL": "THERMOGRAPH_DISCORD_BOT_TOKEN",
"THERMOGRAPH_DISCORD_SUBSCRIPTION_CHANNEL": "THERMOGRAPH_DISCORD_BOT_TOKEN",
# Gateway-bot enable flag: truthy alone does nothing (web/app.py only starts the
# bot when discord_bot.enabled() sees the token too). One gateway connection per
# token — set in exactly one environment.
"THERMOGRAPH_DISCORD_BOT": "THERMOGRAPH_DISCORD_BOT_TOKEN",
}
# Standalone optional keys: reported present/absent, never a "gap" on their own.
STANDALONE_OPTIONAL = [
"THERMOGRAPH_METRICS_TOKEN", # ops metrics remote access (else loopback-only, not broken)
"THERMOGRAPH_DISCORD_WEBHOOK", # daily post
"THERMOGRAPH_DISCORD_PUBLIC_KEY", # slash-command interactions
"THERMOGRAPH_DISCORD_BOT_TOKEN", # bot DMs / gateway bot (prerequisite for the two below)
]
C = {"red": "\033[31m", "yellow": "\033[33m", "green": "\033[32m",
"dim": "\033[2m", "bold": "\033[1m", "off": "\033[0m"}
def keys_from_source(path: str) -> set[str]:
"""Extract KEY names from a SOPS yaml or a plain key-list file (values ignored)."""
with open(path, encoding="utf-8", errors="replace") as fh:
text = fh.read()
keys: set[str] = set()
in_sops_block = False
for raw in text.splitlines():
line = raw.rstrip("\n")
if line.startswith("sops:"): # SOPS metadata block — skip its subkeys
in_sops_block = True
continue
if in_sops_block:
if line and not line[0].isspace():
in_sops_block = False # dedented back to a real key
else:
continue
s = line.strip()
if not s or s.startswith("#"):
continue
# Accept "KEY: ..." (yaml), "KEY=..." (dotenv), or a bare "KEY" (key-list).
# The whole stripped line must be just an identifier optionally followed by
# a : or = — so prose/nested lines don't get mistaken for keys.
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*(?:[:=].*)?$", s)
if m:
keys.add(m.group(1))
return keys
def audit(envs: dict[str, set[str]]) -> int:
names = list(envs)
print(f"{C['bold']}Key-gap audit — {', '.join(names)}{C['off']}")
for n in names:
print(f" {C['dim']}{n}: {len(envs[n])} keys{C['off']}")
print()
critical = required = feature = dependent = 0
def present(env, key): # noqa: ANN001
return key in envs[env]
# Required + self-generating
print(f"{C['bold']}Required secrets{C['off']}")
for key, self_gen in {**REQUIRED, **OPTIONAL_SELF_GEN}.items():
req = key in REQUIRED
missing = [n for n in names if not present(n, key)]
if not missing:
print(f" {C['green']}OK{C['off']} {key} (all environments)")
continue
if req and self_gen:
critical += 1
tag = f"{C['red']}CRITICAL{C['off']}"
note = " — self-generates on boot: MISSING = every session/subscription invalidated"
elif req:
required += 1
tag = f"{C['red']}MISSING {C['off']}"
note = ""
else:
tag = f"{C['yellow']}note {C['off']}"
note = " — self-generates (persisted to a file; low risk)"
print(f" {tag} {key} absent in: {', '.join(missing)}{C['dim']}{note}{C['off']}")
print()
# Feature groups — partial config = broken
print(f"{C['bold']}Feature groups (all-or-nothing){C['off']}")
for feat, keys in FEATURE_GROUPS.items():
for n in names:
have = [k for k in keys if present(n, k)]
if not have:
print(f" {C['dim']}off {feat} @ {n} (none set){C['off']}")
elif len(have) == len(keys):
print(f" {C['green']}OK{C['off']} {feat} @ {n} (enabled)")
else:
feature += 1
miss = [k for k in keys if not present(n, k)]
print(f" {C['red']}GAP {C['off']} {feat} @ {n}: has {have}, MISSING {miss}")
print()
# Dependent optional — the key only does anything with its prerequisite also set
print(f"{C['bold']}Dependent optional keys{C['off']}")
for key, prereq in DEPENDENT_OPTIONAL.items():
for n in names:
if not present(n, key):
print(f" {C['dim']}off {key} @ {n} (unset){C['off']}")
elif present(n, prereq):
print(f" {C['green']}OK{C['off']} {key} @ {n} (enabled, {prereq} set)")
else:
dependent += 1
print(f" {C['red']}GAP {C['off']} {key} @ {n}: set but {prereq} is missing — silently no-ops")
print()
# Standalone optional
print(f"{C['bold']}Optional keys{C['off']}")
for key in STANDALONE_OPTIONAL:
where = [n for n in names if present(n, key)]
state = f"set in: {', '.join(where)}" if where else "unset everywhere"
print(f" {C['dim']}{key}: {state}{C['off']}")
print()
# Cross-env drift
if len(names) > 1:
print(f"{C['bold']}Cross-environment drift{C['off']}")
union = set().union(*envs.values())
drift = False
for key in sorted(union):
where = [n for n in names if key in envs[n]]
if len(where) != len(names) and (key in REQUIRED or key in OPTIONAL_SELF_GEN
or any(key in g for g in FEATURE_GROUPS.values())
or key in DEPENDENT_OPTIONAL
or key in STANDALONE_OPTIONAL):
drift = True
absent = [n for n in names if n not in where]
print(f" {C['yellow']}drift{C['off']} {key}: in {', '.join(where)}; not in {', '.join(absent)}")
if not drift:
print(f" {C['green']}none{C['off']} (tracked keys are consistent)")
print()
total = critical + required + feature + dependent
color = C["red"] if total else C["green"]
print(f"{color}{C['bold']}Summary: {critical} critical, {required} required, "
f"{feature} feature gaps, {dependent} dependent-key gaps{C['off']}")
return 1 if (critical or required) else 0
def main(argv: list[str]) -> int:
args = [a for a in argv[1:] if "=" in a]
if not args:
print(__doc__)
return 2
envs: dict[str, set[str]] = {}
for a in args:
name, _, path = a.partition("=")
try:
envs[name] = keys_from_source(path)
except OSError as e:
print(f"!! {name}: cannot read {path}: {e}", file=sys.stderr)
return 2
return audit(envs)
if __name__ == "__main__":
raise SystemExit(main(sys.argv))