- ci.yaml: no more @latest — pinned to released versions; supply-chain audit clean (govulncheck gates CI, npm audit gate, lockfiles committed, npm ci) - check-env-docs.py: detects env vars read via the getEnv() helper (R2_* blind spot closed); 42 vars documented - .gitignore: .sisyphus/ review reports Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
160 lines
6.7 KiB
Python
160 lines
6.7 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*\)'
|
|
)
|
|
|
|
# Matches the codebase's custom getEnv(key, ...) helper (db/db.go,
|
|
# db/db_dev.go, internal/s3/s3.go, internal/dav/service_dev.go,
|
|
# internal/dav/service_prod.go, handlers/user/profile.go) — the first
|
|
# argument is always a literal env var name.
|
|
GETENV_HELPER_RE = re.compile(r'getEnv\(\s*"([A-Z_][A-Z0-9_]*)"')
|
|
|
|
|
|
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.
|
|
env_vars.update(GETENV_HELPER_RE.findall(content))
|
|
|
|
# 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'}
|
|
|
|
# Test-internal sentinels that are NOT user-configurable variables and so
|
|
# never belong in .env.example (never flag them):
|
|
# - GO_WANT_HELPER_PROCESS: 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).
|
|
# - TEST_DB_VAR / TEST_DB_MISSING_VAR: set by db_test.go's getEnv unit
|
|
# tests (os.Setenv within the test itself), not read from any real
|
|
# environment.
|
|
# - SNAPSHOT_ENC_KEY_BRANCH / WEBHOOK_BRANCH / SQUARE_CRED_BRANCH:
|
|
# re-exec sentinels for the fatal-branch startup tests
|
|
# (startup_checks_test.go, square_http_client_test.go), set via cmd.Env
|
|
# like GO_WANT_HELPER_PROCESS.
|
|
env_vars -= {
|
|
'GO_WANT_HELPER_PROCESS',
|
|
'TEST_DB_VAR', 'TEST_DB_MISSING_VAR',
|
|
'SNAPSHOT_ENC_KEY_BRANCH', 'WEBHOOK_BRANCH', 'SQUARE_CRED_BRANCH',
|
|
}
|
|
|
|
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()
|