thermograph/.claude/skills/key-gaps/key_gaps.py

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_]+=" /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))