Files
Crussell/scripts/check-env-docs.py
T
popertots f9e8385d5a fix: auth/2FA security — stdout-log code delivery is dev/test-only, production fails closed until email/SMS; verification-code hashing, lockout recovery, sabredav fail-closed
- TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in REMOVED: plaintext codes are written to the stdout log ([2FA]/[VERIFY]) only in dev/test builds as a local DEV ONLY feature while email/SMS delivery (P6) is implemented. Production builds have no delivery channel and code issuance fails closed (503) under any configuration — no silent log-based code leak
- verification/2FA codes hashed at rest (HMAC-SHA256 via TWO_FACTOR_PEPPER, CHAR(64)); [VERIFY] dev log relay; per-user brute-force budget; password_reset purpose clears lockout for self-service recovery; dummy-bcrypt on login no-user path kills timing oracle
- sabredav weak-password list + entropy gate; .env.example ships fail-closed DAV_ADMIN_PASSWORD
- delete-account re-auth (current_password + fresh 2FA code when enforced)
- prod-tag suite (run-prod-tag-tests.sh) compiles and runs the production 2FA issuance gate: production ALWAYS reports no delivery channel and refuses issuance after the pepper check
- startup_checks_test SNAPSHOT_ENC_KEY values built at runtime so gitleaks sees no secret-shaped literals
- env-docs parity updated (flag removed, 38 vars)
2026-08-22 00:34:50 +01:00

141 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Check that all env vars used in the codebase are documented in .env.example."""
import os
import re
import sys
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Matches `const foo = "ENV_NAME"` (with optional explicit string type) on a
# single line, plus the same declarations inside a `const ( ... )` block.
CONST_SINGLE_RE = re.compile(r'const\s+(\w+)(?:\s+string)?\s*=\s*"([A-Z_][A-Z0-9_]*)"')
CONST_BLOCK_RE = re.compile(r'const\s*\(([^)]*)\)')
CONST_BLOCK_ITEM_RE = re.compile(r'(\w+)(?:\s+string)?\s*=\s*"([A-Z_][A-Z0-9_]*)"')
# Matches os.Getenv / os.LookupEnv with either a string literal
# (os.Getenv("VAR")) or an identifier (os.Getenv(varName)) that may be a
# const holding the env var name (const-indirect reads).
GETENV_RE = re.compile(
r'os\.(?:Getenv|LookupEnv)\(\s*(?:"([A-Z_][A-Z0-9_]*)"|([A-Za-z_]\w*))\s*\)'
)
def go_const_map():
"""Return {package_name: {const_name: "ENV_NAME"}} from all backend Go files.
Consts are package-scoped in Go: a const may be defined in one file and
referenced from os.Getenv in another file of the same package (e.g.
twoFAPepperEnv defined in twofa.go, read in twofa_prod.go), so collection
must be per package rather than per file.
"""
consts = {} # package -> {name -> value}
go_dir = os.path.join(REPO_ROOT, 'backend')
for root, dirs, files in os.walk(go_dir):
dirs[:] = [d for d in dirs if d not in ('vendor', '.git', 'node_modules')]
for f in files:
if not f.endswith('.go'):
continue
path = os.path.join(root, f)
with open(path) as fh:
content = fh.read()
pkg_match = re.search(r'^package\s+(\w+)', content, re.MULTILINE)
if not pkg_match:
continue
pkg = pkg_match.group(1)
pkg_consts = consts.setdefault(pkg, {})
for name, value in CONST_SINGLE_RE.findall(content):
pkg_consts[name] = value
for block in CONST_BLOCK_RE.findall(content):
for name, value in CONST_BLOCK_ITEM_RE.findall(block):
pkg_consts[name] = value
return consts
def find_env_vars_in_code():
"""Find all env vars read in the codebase (os.Getenv/os.LookupEnv in backend
Go files, import.meta.env.VITE_* in frontend files)."""
env_vars = set()
consts = go_const_map()
# Search backend Go files for os.Getenv / os.LookupEnv reads, including
# const-indirect reads (os.Getenv(constName) resolved via the const map).
go_dir = os.path.join(REPO_ROOT, 'backend')
for root, dirs, files in os.walk(go_dir):
dirs[:] = [d for d in dirs if d not in ('vendor', '.git', 'node_modules')]
for f in files:
if not f.endswith('.go'):
continue
path = os.path.join(root, f)
with open(path) as fh:
content = fh.read()
pkg_match = re.search(r'^package\s+(\w+)', content, re.MULTILINE)
pkg_consts = consts.get(pkg_match.group(1), {}) if pkg_match else {}
for literal, identifier in GETENV_RE.findall(content):
if literal:
env_vars.add(literal)
elif identifier in pkg_consts:
env_vars.add(pkg_consts[identifier])
# Non-const identifiers (e.g. function params like getEnv(key))
# cannot be resolved to a specific env var — skip them.
# Search frontend files for import.meta.env.VITE_* / import.meta.env.*
frontend_dir = os.path.join(REPO_ROOT, 'frontend')
for root, dirs, files in os.walk(frontend_dir):
dirs[:] = [d for d in dirs if d not in ('.git', 'node_modules', 'build')]
for f in files:
if f.endswith(('.ts', '.svelte', '.js')):
path = os.path.join(root, f)
with open(path) as fh:
for line in fh:
m = re.findall(r'import\.meta\.env\.([A-Z_][A-Z0-9_]*)', line)
env_vars.update(m)
# Vite built-ins (DEV, PROD, SSR, MODE, BASE_URL, BUILD) are provided by Vite
# itself and cannot be defined in .env.example — never treat them as user env vars.
env_vars -= {'DEV', 'PROD', 'SSR', 'MODE', 'BASE_URL', 'BUILD'}
# GO_WANT_HELPER_PROCESS is the conventional Go test-internal sentinel for
# the "re-exec self as helper process" pattern (startup_checks_test.go sets
# it via cmd.Env on the re-exec'd binary). It is NOT a user-configurable
# variable — it never belongs in .env.example, so never flag it.
env_vars -= {'GO_WANT_HELPER_PROCESS'}
return sorted(env_vars)
def find_documented_vars():
"""Find all env vars documented in .env.example."""
env_path = os.path.join(REPO_ROOT, '.env.example')
documented = set()
if os.path.exists(env_path):
with open(env_path) as fh:
for line in fh:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
var_name = line.split('=')[0].strip()
if var_name:
documented.add(var_name)
return documented
def main():
code_vars = find_env_vars_in_code()
documented = find_documented_vars()
undocumented = [v for v in code_vars if v not in documented]
if undocumented:
print("ERROR: The following env vars are used in code but not documented in .env.example:\n")
for v in undocumented:
print(f" {v}")
print(f"\nTotal: {len(undocumented)} undocumented vars out of {len(code_vars)} total")
sys.exit(1)
else:
print(f"OK: All {len(code_vars)} env vars used in code are documented in .env.example")
sys.exit(0)
if __name__ == '__main__':
main()